Iso(and Di/Tri)Metric Projection in SVGs

4 hours ago 1

October 2025

x y z +x +y +z -x -y -z x' y' 0,0,0 0,0

I recently put together an animated isometric SVG, and in the process learnt more than any web developer rightly should about axonometric projection.

To save you (or future me) the mental gymnastics of figuring out how to convert from 3D to 2D coordinates here’s the gist of it.

Coordinate System

WebGL and CSS both use right-handed coordinate systems where $+z$ points out of the screen, but in isometric projection there is no axis perpendicular to the display.

This is the closest I could find to a standard for isometric views:

SVG generally uses a left-top origin, but this maps clumsily to a 3D space. A centralised origin is easier to reason with.

SVG has a 2D coordinate system (henceforth $(x^\prime,y^\prime)$). The origin can be centralised with negative min-x/min-y viewBox arguments (e.g. viewBox="-5 -5 10 10").

Mapping 3D to 2D

For a given position $(x,y,z)$ in 3D space the projected $(x^\prime,y^\prime)$ can be determined by considering each axis independently.

In isometric projection all axes are 120° apart. Alternatively the “horizontal” axes are 30° from screen horizontal.

Thus for the $x$ axis:

30° x x' y'

$$ \begin{aligned} x^\prime(x) &= x \cdot cos(30°) \newline y^\prime(x) &= x \cdot sin(30°) \newline \newline \end{aligned} $$

For the $y$ axis:

30° y -x' y'

$$ \begin{aligned} x^\prime(y) &= -y \cdot cos(30°) \newline y^\prime(y) &= y \cdot sin(30°) \newline \newline \end{aligned} $$

And for the $z$ axis:

z -y'

$$ \begin{aligned} x^\prime(z) &= 0 \newline y^\prime(z) &= -z \end{aligned} $$

All together:

$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = f(x,y,z) = \begin{bmatrix} (x - y) \cdot \cos(30°) \newline (x + y) \cdot \sin(30°) - z \end{bmatrix} $$

In TypeScript:

// 30° = π / 6 in radians const xy = ( x: number, y: number, z: number, ): [number, number] => [ (x - y) * Math.cos(Math.PI / 6), (x + y) * Math.sin(Math.PI / 6) - z, ]

Planes

Each axis has a corresponding normal plane.

XY xXY yXY z $XY$ plane perpendicular to $z$ axis

To project onto a plane we can use SVG’s matrix transform function.

<g transform="matrix(a, b, c, d, e, f)"></g>

Ignoring e and f for now:

$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) \newline y^\prime(x) + y^\prime(y) \end{bmatrix} = \begin{bmatrix} a & c \newline b & d \end{bmatrix} \begin{bmatrix} x \newline y \end{bmatrix} = \begin{bmatrix} ax + cy \newline bx + dy \end{bmatrix} $$

For the $XY$ plane, $x_{XY}$ projects onto $+x$ in 3D space, and $y_{XY}$ projects onto $+y$. Substituting the 3D-to-2D equations from above:

$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) \newline y^\prime(x) + y^\prime(y) \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) \newline sin(30°) & sin(30°) \end{bmatrix} \begin{bmatrix} x \newline y \end{bmatrix} $$ $$ \begin{bmatrix} a & c \newline b & d \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) \newline sin(30°) & sin(30°) \end{bmatrix} $$

Thus to project 2D content onto the $XY$ plane:

<svg viewBox="-6 -6 12 12"> <!-- cos(30°) ≈ 0.866 sin(30°) = 0.5 --> <g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 0)"> <text text-anchor="middle" dominant-baseline="middle" y="-2" > Hello World </text> <circle cx="-2" cy="2" r="1"/> <rect x="1" y="1" width="2" height="2"/> </g> </svg>
Hello World Content projected onto the $XY$ plane

Revisiting e & f the $XY$ plane can also be translated along its normal ($z_{XY}$) axis (which projects to $z$ for the $XY$ plane).

$$ \begin{bmatrix} x^\prime \newline y^\prime \newline 1 \end{bmatrix} = \begin{bmatrix} a & c & e \newline b & d & f \newline 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \newline y \newline z \end{bmatrix} = \begin{bmatrix} ax + cy + ez \newline bx + dy + fz \newline z \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) + x^\prime(z) \newline y^\prime(x) + y^\prime(y) + y^\prime(z) \newline z \end{bmatrix} $$ $$ \begin{aligned} x^\prime(z) &= 0 \newline y^\prime(z) &= -z \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) & 0 \newline sin(30°) & sin(30°) & -1 \end{bmatrix} $$

Shifting by one unit in the $-z$ direction is projected as a unit shift in the $+y^\prime$ direction.

Here the purple plane is shifted in the $+z$ direction ($f = -1$) and the orange plane is shifted in the $-z$ direction ($f = 1$):

<svg viewBox="-1 -1 2 2"> <g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 1)"> <rect x="-1" y="-1" width="2" height="2" fill="orange"/> </g> <g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 0)"> <rect x="-1" y="-1" width="2" height="2" fill="seagreen"/> </g> <g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -1)"> <rect x="-1" y="-1" width="2" height="2" fill="rebeccapurple"/> </g> </svg>
f = 1 f = 0 f = -1

By considering axes directions transformation matrices can be intuitively determined for the $XZ$ and $YZ$ planes as well.

XZ xXZ yXZ y $XZ$ plane perpendicular to $y$ axis

$$ \begin{aligned} x_{XZ} \rightarrow +x \newline y_{XZ} \rightarrow -z \newline z_{XZ} \rightarrow +y \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & 0 & -cos(30°) \newline sin(30°) & 1 & sin(30°) \end{bmatrix} $$

transform_xz = `matrix( 0.866, 0.5, 0, 1, ${-0.866 * offset}, ${0.5 * offset} )`
YZ xYZ yYZ x $YZ$ plane perpendicular to $x$ axis

$$ \begin{aligned} x_{YZ} \rightarrow -y \newline y_{YZ} \rightarrow -z \newline z_{YZ} \rightarrow +x \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & 0 & cos(30°) \newline -sin(30°) & 1 & sin(30°) \end{bmatrix} $$

transform_yz = `matrix( 0.866, -0.5, 0, 1, ${0.866 * offset}, ${0.5 * offset})`

Planes can be packaged as a component with an offset prop in your framework of choice:

function XYPlane({ children, offset }) { const transform = `matrix( 0.866, 0.5, -0.866, 0.5, 0, ${offset} )` return ( <g transform={matrix} > {children} </g> ) } <XYPlane offset="1"> <!-- content to project onto XY plane --> </XYPlane>

Cuboids

A cuboid can be rendered by combining 3 rectangles rendered on each of the normal planes.

<XYPlane> <rect x="0" y="0" width="2" height="1" fill="orange" /> </XYPlane> <XZPlane offset="1"> <rect x="0" y="0" width="2" height="1" fill="orange" style="filter: brightness(0.9)" /> </XZPlane> <YZPlane offset="2"> <rect x="-1" y="0" width="1" height="1" fill="orange" style="filter: brightness(0.8)" /> </YZPlane>

This can also be packaged as a component with x/y/z and width/height/depth props, which define the cuboid’s size along the $x$, $y$, and $z$ axes respectively.

<Cuboid x={0} y={0} z={0} w={2} h={1} d={1} />

Other Solids

By considering geometry in $(x,y,z)$ coordinates and using the xy(x,y,z) function more complex solids can also be rendered.

const front = [ [0, 0, 2] [1, 1, 0], [-1, 1, 0], ].map(xyz => xy(...xyz).join(',')).join(' '), right = [ [0, 0, 2], [1, -1, 0], [1, 1, 0], ].map(xyz => xy(...xyz).join(',')).join(' ') <polygon points={front}/> <polygon points={right} style="filter: brightness(0.9)"/>

Other Projection Angles

From a viewing angle normal to the face of a cube, rotating $45°$ about the vertical axis and $\arctan(\frac{1}{\sqrt{2}}) \approx 35.264°$ from the horizontal axis places the subject in isometric projection.

45° 35.264°

Rather than hardcoding the 120°/30° isometric angle, the transformation matrices can be updated to be a function of the viewing angle:

const azimuth = Math.PI / 4, // 45° elevation = Math.atan(Math.SQRT1_2), // ≈ 35.264° matXY = offset => [ Math.cos(azimuth), Math.sin(azimuth) * Math.sin(elevation), -Math.sin(azimuth), Math.cos(azimuth) * Math.sin(elevation), 0, -Math.cos(elevation) * offset, ], matXZ = offset => [ Math.cos(azimuth), Math.sin(azimuth) * Math.sin(elevation), 0, Math.cos(elevation), -Math.sin(azimuth) * offset, Math.cos(azimuth) * Math.sin(elevation) * offset, ], matYZ = offset => [ Math.sin(azimuth), -Math.cos(azimuth) * Math.sin(elevation), 0, Math.cos(elevation), Math.cos(azimuth) * offset, Math.sin(azimuth) * Math.sin(elevation) * offset, ]

The intuition for each value is, given a unit movement in the $x$ or $y$ direction on an untransformed plane, how does that project to $(x^\prime,y^\prime)$ for a given azimuth (viewing angle around the vertical axis) and elevation (viewing angle around the horizontal axis), relative to a starting angle normal to the $\text{YZ}$ plane.

The xy(x,y,z) function can also be updated:

const xy = ( x: number, y: number, z: number, azimuth: number = Math.PI / 4, elevation: number = Math.atan(Math.SQRT1_2), ): [number, number] => [ y * Math.cos(azimuth) - x * Math.sin(azimuth), z * Math.cos(elevation) - x * Math.cos(azimuth) * Math.sin(elevation) - y * Math.sin(azimuth) * Math.sin(elevation), ]

Now alternative (di/tri)metric projections can be achieved by adjusting the viewing angle:

Closing Notes

Attempting to render complex scenes quickly comes up against some hurdles, notably:

  • Elements later in the DOM are rendered “in-front” of earlier elements regardless of their $(x,y,z)$ position.
  • To make things appear 3D shadows have to be manually created.

For more advanced rendering a 3D library such as Three.js may be more appropriate.

But for simple diagramming—with the added benefit of being able to create self-contained SVGs—the above approach may well be sufficient.

Read Entire Article