Daniel Arcé

Design Technologist — AI products, complex systems, accessible interaction

3D Without Canvas or WebGL

Format: field noteEvidence: closed

What this is: a small experiment showing how 3D rotation and projection map to browser rendering using only DOM elements, TypeScript, and CSS transforms. What this is not: a general-purpose 3D engine or a recommended production rendering pipeline.

Why DOM Instead of Canvas?

In this experiment, the cube is not drawn on a canvas. Each vertex is a div element, positioned using CSS transforms.

This is a deliberate constraint: no canvas, no WebGL, no engine; just enough code to make the math visible from matrix multiplication to motion on screen.

The Problem

A cube has eight vertices. Each vertex is a point in space: (x,y,z)(x, y, z).

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 zz moves points in the xyxy-plane. Rotating around xx moves points in the yzyz-plane.

Each axis has its own matrix. In the plane perpendicular to that axis the picture is a 2D rotation: counterclockwise when yy points up (the math convention), clockwise when yy points down (the screen).

A coral arrow starting on the x-axis and turning left by an angle theta, with y pointing up.
θ measured left from +x. This is the usual math picture, with y pointing up.
A coral arrow starting on the x-axis and turning right by an angle theta, with y pointing down.
θ measured right from +x. Screens number y downward, so the same matrix looks clockwise.

Around X

The first row is [1,0,0][1, 0, 0]. This preserves xx:

Rx(θ)=[1000cosθsinθ0sinθcosθ]R_x(\theta) = \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{bmatrix}

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 [0,1,0][0, 1, 0]. No rotation around the yy-axis leaves yy unchanged while rotating points in the xzxz-plane:

Ry(θ)=[cosθ0sinθ010sinθ0cosθ]R_y(\theta) = \begin{bmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{bmatrix}

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 [0,0,1][0, 0, 1]. This preserves zz:

Rz(θ)=[cosθsinθ0sinθcosθ0001]R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix}

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:

[xyz]=R[xyz]\begin{bmatrix} x' \\ y' \\ z' \end{bmatrix} = R \cdot \begin{bmatrix} x \\ y \\ z \end{bmatrix}

The result (x,y,z)(x', y', z') is the rotated position. Do that for every vertex and the whole shape turns. The origin stays put.

A flat x-y plane. A dashed L sits on the x-axis; a coral L is the same figure turned by theta. The turn arc has an arrow.
Apply R to every corner. Same L, turned by θ. Ghost is before, coral is after.

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: RxR_x, then RyR_y, then RzR_z.

This demo uses column vectors and v=Mvv' = Mv (matrix on the left). Composition is right-to-left: apply RxR_x, then RyR_y, then RzR_z. 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 yy, so the same RzR_z looks clockwise on the page. The view step later is not a rotation — it is a projective scale.

Order matters. Rotating first around zz then around xx is not the same as xx then zz. Matrix multiplication is not commutative. Walk it one turn at a time:

Step 1. An L lies on the xy plane. Axes x, y, and z meet at the origin.
1 · Start. The L sits on the xy plane.
Step 2. The L has turned around z. Ghost shows the start pose. The L is still on the floor.
2 · Apply Rz. It turns around z and stays on the floor.
Step 3. The L from step 2 has now tipped around x. Ghost is the after-Rz pose.
3 · Then Rx. That pose tips around x.
Step 4. Other order: the L first tipped around x, then turned around z. A coral arrow marks the last turn. The pose does not match step 3.
4 · Other order: Rx first, then Rz. Same two turns, different pose.
Two coral L poses side by side. Left is Rz then Rx. Right is Rx then Rz. A not-equals sign sits between them.
5 · The two finals. Same turns, opposite order — not the same pose.

A single rotation about an arbitrary axis u^\hat{u} is the same idea in one step: the part of v\vec{v} along u^\hat{u} is fixed; the rest spins in the plane of θ\theta.

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)
}

The goal isn’t to build a general transform stack; it’s to keep the full pipeline visible: matrix multiplication in TypeScript, projection, and DOM updates in the browser.

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 dd (the camera's zz when the plane sits at z=0z = 0). 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:

height on screen=height in space×ddepth\text{height on screen} = \text{height in space} \times \frac{d}{\text{depth}}

Depth here is dzd - z. Farther away means a larger depth, so a smaller mark. Same construction as Bx=Axd/AzB_x = A_x \cdot d / A_z.

An eye in front of a screen. A coral tree in space spans height in space; its projection on the screen spans height on screen. A ray through both tops. Braces mark d and depth. The equation height on screen = height in space times d over depth sits at the top left.
Height on screen is a shrink of height in space. Same colors as the braces.

The demo stores dd as eyeDistance and folds the shrink into one perspective scale s=1/(dz)s = 1/(d - z). Same dd, two depths: the far tree lands closer to the middle, and smaller.

An eye, a screen, and two same-size coral trees at different depths. Green rays run from the eye to each tree’s top and base. A stacked key marks solid as near and dashed as far. On the screen the near image is large and the far image is small and closer to the middle, both fully on the film. Braces mark d and d minus z.
Perspective scale = 1/(d − z). Solid rays = near, dashed = far. Farther away → smaller on the screen, closer to the middle.
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
}

This simplified projection works for a small demo, though in a more robust renderer you’d guard against points crossing the projection plane.

Each frame, eight vertices become eight screen positions. Those positions render as the dots you see above.

Again: no canvas, no 3D engine—just math → DOM updates.

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.

DOM, GPU, and Seeing the Math

Using translate3d lets the browser composite vertices on the GPU. For eight tiny divs, that’s plenty: the GPU moves a few layers while the CPU focuses on the math.

This demo isn’t about “beating” WebGL. It’s about keeping the entire 3D pipeline visible, rotation & projection, so you can read the math directly in TypeScript and see the result on screen.

Here's the full chain:

  1. Take a 3D point
  2. Apply rotation matrices
  3. Project to 2D
  4. Push the result into a CSS transform

The complete code

The functions from the article, in one place, in the order the cube runs them. This is lib/matrixTransformations.ts plus the vertex divs.

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
            )`
        }}
    />
))}