3D Without Canvas or WebGL
Format: field noteEvidence: closed
Move your mouse over the cube to rotate it. Each of its eight vertices is a div; TypeScript computes the rotated coordinates and CSS transforms position the dots.
The DOM rendering is a deliberate constraint that keeps the calculation visible, from matrix multiplication to motion on screen.
Rotate eight points
A cube has eight vertices. Each vertex is a point in space: .
To rotate the cube, we need new coordinates for every vertex—coordinates that place each point in its rotated position.
Rotation matrices perform that transformation. In this demo, the math is done directly in TypeScript and the result is pushed into CSS transforms.
One Axis at a Time
Rotation around an axis leaves that axis unchanged. Rotating around moves points in the -plane. Rotating around moves points in the -plane.
Angles below are in radians: a quarter turn is . Coordinates are multiplied as column vectors, and these are active rotations: points move while the axes stay fixed. The right-hand convention determines each sign, as in Lynch and Park’s Modern Robotics rotation-matrix explanation.
Each axis has its own matrix. In the plane perpendicular to that axis the picture is a 2D rotation: counterclockwise when points up (the math convention), clockwise when points down (the screen).
Around X
The first row is . This preserves :
const rotationX = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[1, 0, 0],
[0, Math.cos(angle), -Math.sin(angle)],
[0, Math.sin(angle), Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Around Y
The middle row is . A rotation around the -axis leaves unchanged while rotating points in the -plane:
const rotationY = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), 0, Math.sin(angle)],
[0, 1, 0],
[-Math.sin(angle), 0, Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Around Z
The last row is . This preserves :
const rotationZ = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), -Math.sin(angle), 0],
[Math.sin(angle), Math.cos(angle), 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Applying a Rotation
To rotate a vertex, multiply the matrix by its coordinates:
For example, : positive turns to positive . Rotation preserves length; only the later scaling and projection change the on-screen size.
The result is the rotated position. Do that for every vertex and the whole shape turns. The origin stays put.
In this demo, that math is done in plain TypeScript:
const matrixMultiplyVertex = (matrix: number[][], vertex: Vertex): Vertex => {
const result: number[][] = []
for (let i = 0; i < matrix.length; i++) {
let sum = 0
sum += matrix[i][0] * vertex.x
sum += matrix[i][1] * vertex.y
sum += matrix[i][2] * vertex.z
result[i] = [sum]
}
return { x: result[0][0], y: result[1][0], z: result[2][0] }
}
Combining Rotations
The cube above uses all three matrices in sequence: , then , then .
This demo uses column vectors and (matrix on the left). Composition is right-to-left: apply , then , then . Rotations are active (they move the cube; the axes stay put) and right-handed: a positive angle is counterclockwise when you look along the axis toward the origin. Screens flip , so the same looks clockwise on the page. The view step later is not a rotation — it is a projective scale.
Order matters. Rotating first around then around is not the same as then . Matrix multiplication is not commutative. Walk it one turn at a time:
A single rotation about an arbitrary axis is the same idea in one step: the part of along is fixed; the rest spins in the plane of .
export const transformPoints = (
vertexArray: Vertex[],
angleX: number,
angleY: number,
angleZ: number,
scale: number,
eyeDistance: number,
) => {
let rotatedPoints = rotationX(vertexArray, angleX)
rotatedPoints = rotationY(rotatedPoints, angleY)
rotatedPoints = rotationZ(rotatedPoints, angleZ)
const scaledPoints = scaleXYZ(rotatedPoints, scale)
return projectPoints(scaledPoints, eyeDistance)
}
From 3D to Screen
After rotation the cube is still in 3D. The screen is 2D.
A ray from the eye through the screen hits the point. Where it crosses the screen is what you draw.
The gap from the eye to the screen is eye distance (the camera's when the plane sits at ). The two triangles — eye to the screen hit, and eye to the point — are the same shape, so height on screen is a shrink of height in space:
Depth here is . Farther away means a larger depth, so a smaller mark. In coordinates measured from the eye, the same horizontal construction is : then denotes eye-relative depth, rather than the demo's world-space .
The demo stores as eyeDistance and uses a normalized perspective scale . This omits the fixed numerator from the screen-plane formula above, giving a different overall scale while retaining the inverse-depth relationship. At the same eye distance, farther points land closer to the centre.
The next plate keeps the physical screen-plane construction: its scale is . The long brace runs from the eye to the near object's depth, not from the screen to the object. Dividing the drawn screen coordinates by gives the demo's normalized coordinates.
const projectPoints = (vertexArray: Vertex[], eyeDistance: number): Vertex[] => {
const result: Vertex[] = []
vertexArray.forEach(vertex => {
const perspectiveScale = 1 / (eyeDistance - vertex.z)
const projectionMatrix = [
[perspectiveScale, 0, 0],
[0, perspectiveScale, 0],
[0, 0, 1]
]
result.push(matrixMultiplyVertex(projectionMatrix, vertex))
})
return result
}
For , a point with and projects to ; at it projects to . The denominator is zero at the eye plane , not at the screen plane . A robust renderer must clip points before they reach or cross the eye plane; this demo does not implement that clipping.
Each frame, eight vertices become eight screen positions. Those positions render as the dots you see above.
The Render Loop
React manages the vertices as state. When mouse movement triggers a recalculation, the component re-renders with new coordinates:
{vertices.map((vertex, index) => (
<div
key={index}
className={styles.vertex}
style={{
// Template literal: backticks allow embedded expressions ${...}
// to inject computed coordinates directly into the CSS string
transform: `translate3d(
${vertex.x + center.x}px,
${vertex.y + center.y}px,
${vertex.z}px
)`
}}
/>
))}
The browser handles the actual positioning. We compute the math; CSS does the rendering.
The browser decides how to composite the transformed elements. This experiment makes the coordinate calculation inspectable; it supplies no GPU-performance measurement or production-rendering comparison.
The complete code
This reference puts the transformation steps together so they can be read without moving between sections. It is a simplified equivalent of lib/matrixTransformations.ts, whose multiplication routine handles matrix dimensions explicitly. Despite its name, scaleXYZ scales only and ; is left unchanged. The final JSX fragment belongs inside the React component, where vertices, styles, and center are supplied. The fragment and functions are not a standalone application.
interface Vertex {
x: number
y: number
z: number
}
const rotationX = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[1, 0, 0],
[0, Math.cos(angle), -Math.sin(angle)],
[0, Math.sin(angle), Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const rotationY = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), 0, Math.sin(angle)],
[0, 1, 0],
[-Math.sin(angle), 0, Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const rotationZ = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), -Math.sin(angle), 0],
[Math.sin(angle), Math.cos(angle), 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const transformPointsWithMatrix = (vertexArray: Vertex[], matrix: number[][]): Vertex[] => {
return vertexArray.map(vertex => matrixMultiplyVertex(matrix, vertex))
}
const matrixMultiplyVertex = (matrix: number[][], vertex: Vertex): Vertex => {
const result: number[][] = []
for (let i = 0; i < matrix.length; i++) {
let sum = 0
sum += matrix[i][0] * vertex.x
sum += matrix[i][1] * vertex.y
sum += matrix[i][2] * vertex.z
result[i] = [sum]
}
return { x: result[0][0], y: result[1][0], z: result[2][0] }
}
const scaleXYZ = (vertexArray: Vertex[], scale: number): Vertex[] => {
const scaleMatrix = [
[scale, 0, 0],
[0, scale, 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, scaleMatrix)
}
const projectPoints = (vertexArray: Vertex[], eyeDistance: number): Vertex[] => {
const result: Vertex[] = []
vertexArray.forEach(vertex => {
const perspectiveScale = 1 / (eyeDistance - vertex.z)
const projectionMatrix = [
[perspectiveScale, 0, 0],
[0, perspectiveScale, 0],
[0, 0, 1]
]
result.push(matrixMultiplyVertex(projectionMatrix, vertex))
})
return result
}
export const transformPoints = (
vertexArray: Vertex[],
angleX: number,
angleY: number,
angleZ: number,
scale: number,
eyeDistance: number,
) => {
let rotatedPoints = rotationX(vertexArray, angleX)
rotatedPoints = rotationY(rotatedPoints, angleY)
rotatedPoints = rotationZ(rotatedPoints, angleZ)
const scaledPoints = scaleXYZ(rotatedPoints, scale)
return projectPoints(scaledPoints, eyeDistance)
}
{vertices.map((vertex, index) => (
<div
key={index}
className={styles.vertex}
style={{
transform: `translate3d(
${vertex.x + center.x}px,
${vertex.y + center.y}px,
${vertex.z}px
)`
}}
/>
))}