GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Isometric CSS: 9 True-Projection 3D Dioramas

Isometric css done right: Each cube face shades by a light-angle calc, grid coords map to screen via the 2:1 isometric formula, and a painter algorithm sorts

Auto-plays · click a tile to jump to its section · all nine in one zip

The order climbs the same way the math compounds: item 01 fixes the projection itself, 02 turns that projection into a css isometric grid so any tile lands in the right pixel, 03 and 04 layer shading and depth on top of that grid, 05 and 06 add interaction, and 07 through 09 assemble everything into finished scenes a visitor actually recognizes as a room, a city, or a hero banner.

Why not rotateX(-30deg) — the true isometric matrix and coordinate transform

The css isometric cube most tutorials teach is rotateX(-30deg), and it looks close enough until a ruler lands on the screen: a true isometric projection foreshortens all three axes by the exact same amount, and only one angle does that.

The exact angle

That angle is -arctan(1/sqrt(2)), roughly -35.264deg, and item 01 gets there by hand-multiplying 4x4 matrices in JS (mat4RotateX, mat4RotateZ, mat4Mul) rather than trusting a shortcut, then feeding the product into a single matrix3d() string on the world container.

Grid coordinates as a formula

Once the world is tilted, item 02 turns a tile's (col, row) into screen pixels through x = (col - row) * TILE_W / 2 and y = (col + row) * TILE_H / 2, with TILE_W/TILE_H fixed at 34/17 — a 2:1 ratio — and stored as --tx/--ty custom properties so resizing the grid means rerunning the formula, never nudging a div by eye.

Brightness is a light angle — face shading and stacking order math

Two of the nine items carry the rest: everything that reads as "3D" here is either a brightness number or a stacking number, never a drawn shadow or a hand-set layer order.

Face shading recomputes live

Item 03's shade(theta) recomputes each face's brightness from a dot product every frame, and item 09's sky color runs the same live-recompute idea with a plain RGB lerp(DAY, NIGHT, curT) instead — nudging the driving number in either scene (theta or curT) changes every affected surface at once instead of one hardcoded face lagging behind.

Depth order recomputes live

Item 04's traveler and item 06's exploded layers both treat z-index as a formula output rather than a fixed property — (col + row) * 10 for a moving block, GAP * (LAYERS.length - i) for a layer's spread distance — so crossing the grid or opening the stack changes the number, not a manually reordered DOM.

Code per scene in nine steps

Every item below reuses the same isometric matrix and grid formula from this section, then adds exactly one more idea — shading, stacking order, pointer input, or a finished composition — so the ninth scene is really the first scene's math wearing a city block.

01The real isometric — an exact projection matrix instead of rotateX(-30deg)

This item spins an approximate cube next to a true isometric one at the same turntable speed so the skew is visible rather than theoretical.

Building the matrix

mat4RotateX(-35.264) and mat4RotateZ(45 + spin) are multiplied together every frame at 24deg/s (one revolution every 15 seconds), and the live height/width ratio printed under each cube is just cos() of that tilt angle.

The real isometric — an exact projection matrix instead of rotateX(-30deg)
Open in a new tab

01 · The real isometric — an exact projection matrix instead of rotateX(-30deg) — Measured area 8.516% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

matrix3dtransform-stylebackface-visibility

What the numbers say

Painted area sits at 8.5% with a 2.0% peak frame and an average intensity of 67.5, which is expected for a scene built around one small rotating pair of cubes rather than a screen-filling composition.

function mat4RotateX(deg) {
  var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
  return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
}
function mat4RotateZ(deg) {
  var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
  return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function mat4Mul(a, b) {
  var out = new Array(16);
  for (var col = 0; col < 4; col++) {
    for (var row = 0; row < 4; row++) {
      var sum = 0;
      for (var k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
      out[col * 4 + row] = sum;
    }
  }
  return out;
}

02Grid-to-screen — the 2:1 formula turning (col, row) into (x, y)

This item lays a 6x6 grid of tiles and lifts them in a traveling wave so the coordinate math stays visible even while nothing moves horizontally.

The formula in a loop

Each tile's x/y come straight from (col, row), and a wave phase of (col + row) * 0.9 - t * 3.1 drives the vertical lift, so the same two-line formula both places every tile and animates it.

Grid-to-screen — the 2:1 formula turning (col, row) into (x, y)
Open in a new tab

02 · Grid-to-screen — the 2:1 formula turning (col, row) into (x, y) — Measured area 8.835% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translate3dCSS custom propertiescalc

What the numbers say

Painted area is 8.8% with a 3.2% peak frame and an average intensity of 45.9 across the 36-cell grid, all 23 of 23 frames moving as the wave passes through.

var minX = Infinity, maxX = -Infinity, maxY = -Infinity;
for (var row = 0; row < N; row++) {
  for (var col = 0; col < N; col++) {
    var x = (col - row) * TILE_W / 2;
    var y = (col + row) * TILE_H / 2;
    if (x < minX) minX = x;
    if (x > maxX) maxX = x;
    if (y > maxY) maxY = y;
    tiles.push({ col: col, row: row, x: x, y: y });
  }
}
var shiftX = -(minX + maxX) / 2;
var shiftY = -maxY / 2;

03Face shading — computing three faces' brightness from a light angle

This item spins a light vector around a cube once every 9 seconds so each of the three visible faces brightens and dims on its own schedule.

The dot product

dot3() multiplies each face's normal against the light vector, clamp() keeps the result in 0..1, and brightness = 0.32 + 0.92 * dot is what actually lands in filter: brightness() — three separate numbers from one shared formula, never a hand-picked color per face.

Face shading — computing three faces' brightness from a light angle
Open in a new tab

03 · Face shading — computing three faces' brightness from a light angle — Measured area 5.965% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

filter: brightnessCSS custom propertieslinear-gradient

What the numbers say

Painted area is 6.0% with a 5.1% peak frame and an average intensity of 67.9, all 23 of 23 frames moving as the light orbits.

function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }

function shade(theta) {
  var r = theta * Math.PI / 180;
  var raw = [Math.cos(r), Math.sin(r), 0.55];
  var len = Math.sqrt(raw[0] * raw[0] + raw[1] * raw[1] + raw[2] * raw[2]);
  var L = [raw[0] / len, raw[1] / len, raw[2] / len];
  var bTop = 0.32 + 0.92 * clamp(dot3(N_TOP, L), 0, 1);
  var bRight = 0.32 + 0.92 * clamp(dot3(N_RIGHT, L), 0, 1);
  var bFront = 0.32 + 0.92 * clamp(dot3(N_FRONT, L), 0, 1);
  faces.top.style.filter = 'brightness(' + bTop.toFixed(2) + ')';
  faces.right.style.filter = 'brightness(' + bRight.toFixed(2) + ')';
  faces.front.style.filter = 'brightness(' + bFront.toFixed(2) + ')';
  pTop.textContent = String(Math.round(bTop * 100));
  pRight.textContent = String(Math.round(bRight * 100));
  pFront.textContent = String(Math.round(bFront * 100));
  var R = mm ? 58 : 84;
  sun.style.setProperty('--sx', (Math.cos(r) * R).toFixed(1) + 'px');
  sun.style.setProperty('--sy', (Math.sin(r) * R * 0.5).toFixed(1) + 'px');
}

04Stacking order math — a painter's algorithm turning depth into z-index

This item fixes a grid of blocks once and sends one traveler circling through them, so the only thing anyone needs to watch is whether front and back ever look wrong.

One formula, computed twice

Fixed blocks get z-index = Math.round((col + row) * 10) a single time at build, but the traveler recomputes that exact formula from its own (col, row) on every frame it moves, plus one point so it wins ties on a shared cell.

Stacking order math — a painter's algorithm turning depth into z-index
Open in a new tab

04 · Stacking order math — a painter's algorithm turning depth into z-index — Measured area 4.119% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

z-indexCSS custom propertiescalc

What the numbers say

Painted area is 4.1% with a 0.9% peak frame, but average intensity is the highest of all nine items at 127.4, all 23 of 23 frames moving as the traveler circles.

var traveler = document.createElement('div');
traveler.className = 'ps__block ps__block--traveler';
traveler.style.setProperty('--w', SIDE + 'px');
world.appendChild(traveler);

var CENTER = (N - 1) / 2;
var RADIUS = CENTER + 0.35;

function paint(t) {
  var ang = t * 0.9;
  var col = CENTER + Math.cos(ang) * RADIUS;
  var row = CENTER + Math.sin(ang) * RADIUS;
  place(traveler, col, row);
  var z = Math.round((col + row) * 10) + 1;   // traveler wins ties on the same cell
  traveler.style.zIndex = String(z);
  zEl.textContent = String(z);
}

05Pointer parallax — the math behind a diorama that gently follows

This item tilts the same isometric matrix by a small extra amount driven by wherever a visitor's pointer sits inside the frame.

Clamp, then lerp, then settle

Pointer coordinates are normalized to -1..1 and clamped to +-9deg as a target angle, but the visible tilt never jumps there directly — cur += (target - cur) * 0.16 runs inside requestAnimationFrame, and after 1.1 seconds of no input the target itself switches to a sine/cosine sweep so the scene never just stops.

Pointer parallax — the math behind a diorama that gently follows
Open in a new tab

05 · Pointer parallax — the math behind a diorama that gently follows — Measured area 1.605% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermoverequestAnimationFrameclamp

What the numbers say

Painted area is 1.6% with a 0.5% peak frame and an average intensity of 67.0, all 23 of 23 frames moving between the pointer path and the idle sweep.

field.addEventListener('pointermove', function (ev) {
  var rect = field.getBoundingClientRect();
  var nx = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
  var ny = ((ev.clientY - rect.top) / rect.height) * 2 - 1;
  targetX = clamp(-ny * LIMIT, -LIMIT, LIMIT);
  targetY = clamp(nx * LIMIT, -LIMIT, LIMIT);
  lastInput = performance.now();
});
field.addEventListener('pointerleave', function () { lastInput = -Infinity; });

function autoPath(t) {
  targetX = Math.sin(t * 0.6) * LIMIT * 0.85;
  targetY = Math.cos(t * 0.42) * LIMIT;
}

function paint(now, t) {
  if (now - lastInput > IDLE_MS) autoPath(t);
  curX += (targetX - curX) * 0.16;
  curY += (targetY - curY) * 0.16;
  tilt.style.transform = 'rotateX(' + curX.toFixed(2) + 'deg) rotateY(' + curY.toFixed(2) + 'deg)';
  rxEl.textContent = curX.toFixed(1);
  ryEl.textContent = curY.toFixed(1);
}

06Exploded view — a hover that reuses depth order as spread distance

This item stacks four layers — roof, attic, room, floor — and pulls them apart along the same axis a painter's algorithm already sorts them on.

Depth order becomes distance

Each layer's open-state translateZ is GAP * (LAYERS.length - i), computed once per layer at build and stored as --tz-open, so the roof (index 0, the top layer) travels the farthest and a CSS transition does the rest on hover.

Exploded view — a hover that reuses depth order as spread distance
Open in a new tab

06 · Exploded view — a hover that reuses depth order as spread distance — Measured area 11.103% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateZtransitionCSS custom properties

What the numbers say

Painted area is 11.1% with an 8.0% peak frame and an average intensity of 99.1, but only 12 of 23 frames actually move because the scene holds still once a hover finishes opening or closing.

var GAP = mm ? 12 : 18;
gapEl.textContent = String(GAP);

var LAYERS = [
  { name: 'roof', w: 96 },
  { name: 'attic', w: 84 },
  { name: 'room', w: 84 },
  { name: 'floor', w: 96 }
];

for (var i = 0; i < LAYERS.length; i++) {
  var layer = document.createElement('div');
  layer.className = 'ev__layer ev__layer--' + LAYERS[i].name;
  layer.style.setProperty('--w', (mm ? LAYERS[i].w * 0.78 : LAYERS[i].w) + 'px');
  // reverse order -- the roof is the topmost layer, so it must rise the most when exploded
  layer.style.setProperty('--tz-open', (GAP * (LAYERS.length - i)) + 'px');
  world.appendChild(layer);
}

07The room diorama — a finished scene assembled from 01-04

This item takes the grid, the shading, and the depth math from the earlier items and uses them to build one recognizable room: a floor, two walls, and a roof.

One transition, three delays

The floor sits fixed in place with no transition at all; only the left wall, right wall, and roof share the exact same transition: transform 600ms rule and differ solely by --d — 0ms, 140ms, and 320ms — so the build order comes entirely from three numbers, not three separate animations.

The room diorama — a finished scene assembled from 01-04
Open in a new tab

07 · The room diorama — a finished scene assembled from 01-04 — Measured area 9.435% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

matrix3dCSS custom propertiestransition

What the numbers say

Painted area is 9.4% with a 6.3% peak frame and an average intensity of 65.7, with 16 of 23 frames moving as the room assembles and rests.

.rb__wall {
  position: absolute;
  left: 0;
  top: 0;
  width: 56px;
  height: 46px;
  transform-origin: bottom center;
  transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
  transition-delay: var(--d, 0ms);
  border: 1px solid rgba(23, 20, 26, 0.3);
  border-radius: 4px;
}

08City block — a scene where each building's height comes from the layout math

This item runs the same coordinate and depth formulas across a 3x3 grid of buildings, but hands every building's height and window timing to a seeded random generator instead of a fixed value.

A seed instead of Math.random()

mulberry32(42) produces the same sequence every run, so building heights (HMIN + rand() * HRANGE) and each window's flicker delay reproduce identically across a screenshot, a gif capture, and a live reload — swapping the seed is the only way to get a different skyline.

City block — a scene where each building's height comes from the layout math
Open in a new tab

08 · City block — a scene where each building's height comes from the layout math — Measured area 1.497% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CSS custom properties@keyframesrandom

What the numbers say

Painted area is the lowest of the nine at 1.5%, with a 0.3% peak frame and the darkest average intensity, 17.3, since most of the frame is unlit window background rather than moving pixels.

function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

var SEED = 42;

09The hero scene — day-night lighting and parallax turning together

This item folds item 03's shading and item 05's parallax into one banner-sized scene where a visitor's own pointer height decides whether it reads as noon or midnight.

One number driving sky, windows, and orbit

The pointer's vertical position becomes curT, lerped at 0.14 per frame, and that single 0..1 number interpolates the sky color from [91,140,248] to [23,20,26], raises window opacity, and swings a sun/moon along oy = -cos(curT * pi) * 76 — past curT > 0.5 the scene flips into its night class.

The hero scene — day-night lighting and parallax turning together
Open in a new tab

09 · The hero scene — day-night lighting and parallax turning together — Measured area 21.418% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CSS custom propertiestransitionpointermove

What the numbers say

Painted area is the widest of the set at 21.4%, with a 21.2% peak frame and an average intensity of 37.6, all 23 of 23 frames moving as day turns to night and back.

curT += (targetT - curT) * 0.14;

var r = Math.round(lerp(DAY[0], NIGHT[0], curT));
var g = Math.round(lerp(DAY[1], NIGHT[1], curT));
var b = Math.round(lerp(DAY[2], NIGHT[2], curT));
sky.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';

for (var i = 0; i < wins.length; i++) {
  wins[i].style.opacity = String((0.15 + curT * 0.85).toFixed(2));
}

var isNight = curT > 0.5;
if (isNight !== wasNight) {
  field.classList.toggle('is-night', isNight);
  wasNight = isNight;
}

var orbY = mm ? 60 : 76;
var ox = (curT - 0.5) * (mm ? 120 : 150);
var oy = -Math.cos(curT * Math.PI) * orbY;
sun.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';
moon.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';

pctEl.textContent = String(Math.round(curT * 100));

The isometric matrix and a frozen z-index: where this breaks

Nine near-identical mistakes would have made every one of these look isometric while actually being wrong. Calling rotateX(-30deg) isometric skips the real angle and quietly skews the area of all three faces by different amounts, and stacking CSS perspective() on top of the matrix3d isometric matrix is worse — isometric is a parallel projection, and perspective introduces a vanishing point that fights it, which item 01 never does. Hardcoding grid coordinates as eyeballed pixels instead of x = (col - row) * TILE_W / 2 means every resize is a manual remeasuring job, and assigning each cube face a fixed hex color instead of running item 03's dot product means the light angle can move all day without the brightness relationship between faces ever actually changing. Freezing a moving block's z-index once at scene build instead of recomputing item 04's formula every frame is the single most common isometric bug — a block that should now read as "in front" silently stays behind because nobody asked it to check again. Skipping clamp() on item 05's pointer parallax lets the rotation angle overshoot at the screen edge until the scene visibly smears, and redrawing item 08's building heights with Math.random() instead of a seeded mulberry32 generator means a screenshot from this post never matches a fresh render of the same prompt — r5cadxdw unlocks the full nine-scene zip specifically so a reader can diff the seeded output against their own run rather than trust a claim. Freezing item 05 or item 09 outright when no pointer is present, instead of switching to the autonomous sine/cosine sweep after 1.1 seconds idle, would leave a gif capture or a screen recording stuck on one dead frame. And showing a prefers-reduced-motion visitor an empty canvas instead of one settled frame with the layout already complete treats an accessibility signal as permission to remove information rather than motion.

Where these nine scenes actually earn a spot on a page

A hero section wants item 09: the day-night lighting and pointer parallax share one curT value, so the backdrop keeps moving without asking the visitor to scroll, and stacking a headline or a CTA on top only means respecting its z-index order, not fighting a heavier canvas particle field.

An about page or a static section background is a better fit for item 07 — it assembles items 01 through 04 once and settles once the build animation ends, so it never competes for attention with paragraph text a visitor is actually reading. Item 08 works the opposite way on a features or pricing section: the seeded building heights change on every reload, so a background behind repeat visits doesn't look like the exact same screenshot each time.

For traffic that's mostly touch, pairing item 05's pointer parallax alone is the wrong call — item 09's idle-to-autoplay sweep after 1.1 seconds keeps the scene visibly alive even when there's no pointer to read.

Dial back the light orbit and pointer sweep, and accessibility follows

Variant What changes What it trades off
Isometric angle Item 01's tilt from -35.264deg to 45deg (military projection) The exact isometric angle reads all three faces as equally important; tilting to 45deg emphasizes the top face and reads more like a map or a game board
Light angle Item 03's light from top-left 45deg to straight-on 0deg A top-left light gives crisp depth; a straight-on light narrows the brightness gap between faces into a calmer, flatter illustration feel
Grid density Item 02's grid from 6x6 to 16x16 A denser grid makes a city scene feel richer, but the per-tile calculation load grows and low-end devices can drop frames

Reduced motion, item by item

Every item checks window.matchMedia('(prefers-reduced-motion: reduce)') once at load, and none of them fall back to a blank frame. Item 03 freezes the light angle at 35deg instead of continuing its 9-second orbit, item 06 leaves its four layers in their closed stack rather than cycling open and shut on a timer, item 08 stops the window-flicker keyframe in plain CSS and fixes every window to its lit color instead of freezing mid-flicker in the dark, and item 09 settles curT at 0.6 — just past the day/night threshold — so a reduced-motion visitor sees lit windows and a risen moon instead of an empty sky. This site's a scene whose tilt is driven entirely by pointer coordinates fed through the same clamp-and-lerp math leans on the identical idle-to-autoplay switch item 05 and item 09 use here, and its a wheel whose resting angle is derived by formula rather than picked by hand recomputes a different formula every frame for the same reason item 04's traveler does.

The questions that come up before wiring these nine scenes into a page

Does a css isometric transform need CSS perspective to look right?

No — mixing perspective() into an isometric matrix3d actively breaks it, since isometric is a parallel projection and perspective introduces a vanishing point that the exact -35.264deg angle was never designed to sit inside.

Why does item 08's city look different every time I reload the demo without touching the code?

It shouldn't, and if it does the seed changed somewhere — mulberry32(42) is deterministic by design, so the same seed always produces the same building heights and window-flicker delays across reloads and captures alike.

Can I combine two of these nine into one scene, like a city block with pointer parallax?

Yes, and item 09 is proof it works — it already layers a day-night lighting recompute with item 05's clamp-then-lerp parallax formula on the same isometric matrix, so adding item 08's seeded buildings on top means reusing existing functions rather than writing a new projection.

Enter the archive password

The password is inside this article. You will find it as you read.