Two unrelated techniques both get called "CSS isometric" and this file covers both, clearly separated:
transform-style: preserve-3d with rotateX/rotateY/
translateZ, composited by the browser's own 3D transform pipeline. Gives you an
actual rotated cube in space; individual faces can still have their own hover/light
states, box-shadows, and nested DOM content.rotate() skewX()/skewY() scaleY()
applied to a flat, 2D element fakes the same visual result using pure matrix math.
Cheaper, has none of the 3D-context gotchas (no perspective, no z-fighting), but
every face needs a different 2D recipe and there's no real depth to hang shadows
or lighting on.Per the projection decision (see references/projection-math.md): both routes below
implement true isometric projection (30°/120°, 81.65% axonometric foreshortening
for the 3D route; the SSR-derived 86.602% family for the 2D route). If you actually
need 2:1 dimetric ("game isometric" — commonly called isometric in games) — e.g. to
match pixel-art tile assets 1:1 — swap in the 2:1 dimetric angles from
references/projection-math.md (rotateX(60deg) derivatives, 2:1 skew ratios) and
say so explicitly; do not call a 2:1 CSS grid "isometric" unqualified.
The exact true-isometric camera transform, derived and ground-truth-checked in
references/projection-math.md:
.iso-true {
transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
}
54.7356deg = atan(√2) = 90° − 35.264° — tilts the view down onto the cube's
space-diagonal vertex, the same angle a physical isometric camera would use.-45deg — spins the cube so two vertical edges disappear behind the front corner,
leaving exactly three faces visible (top, left, right).scale3d(1.22474, …) = √(3/2) = 1/cos(35.264°) — undoes the true-projection
foreshortening. A raw isometric camera view flattens depth to 81.65% of its
original size (cos(35.264°)); the 1.22474 scale-up brings 1:1 CSS pixel/unit
edges back to their nominal size so a 100×100px element still measures 100px along
each visible edge. Drop the scale3d term if you deliberately want the
foreshortened "camera photograph" look instead of the "isometric drawing" look —
see the drawing-vs-projection distinction in references/projection-math.md.transform-style: preserve-3d is mandatory on every ancestor of the faces you want
composited in 3D — not just the rotated container, but every intermediate wrapper
between it and the face elements. The moment one ancestor omits it (or the browser
defaults it back to flat, which is the default value), that ancestor flattens its
children into a 2D plane and the whole cube collapses. This single missing property
is the most common reason a "3D CSS isometric" demo renders as a flat rectangle —
verified against Envato Tuts+ "How to Create an Isometric Layout With CSS 3D
Transforms" and the CodePen family deriving this exact stack (search
scootman "CSS 3D transforms: true isometric" on CodePen for a live reference).
.scene { perspective: 1200px; } /* optional: adds a vanishing point, off by default = orthographic-like */
.iso-true,
.iso-true * { transform-style: preserve-3d; }
Note: true isometric projection is itself orthographic (parallel projection, no
vanishing point). Setting a CSS perspective on an ancestor introduces a camera
perspective that competes with the isometric look — for a faithful true-iso render,
omit perspective entirely (or set it very large, effectively flattening its effect)
so parallel edges in 3D stay parallel on screen.
Build a cube (or any box: a tile, a card, a UI panel) from six independently
stylable faces, each a normal DOM element you can put content, gradients, or
box-shadow on:
<div class="cube">
<div class="face top"></div>
<div class="face front"></div>
<div class="face right"></div>
</div>
.cube {
position: relative;
width: 200px; height: 200px;
transform-style: preserve-3d;
transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
}
.face {
position: absolute;
width: 200px; height: 200px;
backface-visibility: hidden; /* skip rendering faces that end up pointing away */
}
/* Top face: rotate flat into the XZ-plane, then push up by half the cube's depth */
.top { transform: rotateX(90deg) translateZ(100px); }
/* Front (left-visible) face: sits at its native orientation, pushed toward camera */
.front { transform: translateZ(100px); }
/* Right face: rotate around Y into the YZ-plane, pushed out by half the width */
.right { transform: rotateY(90deg) translateZ(100px); }
Only the three faces that end up facing the (post-rotateX(54.7356) rotateZ(-45))
camera are visible — the opposite three faces are either behind the visible ones or
back-face-culled by backface-visibility: hidden. This is exactly the "all three
cube faces equal" true-isometric property from the canonical rig table in
references/projection-math.md and references/blender-prerender.md: because the
tilt is 54.736° (not 60°), the top/front/right faces render as three congruent
rhombi, not a mix of shapes.
For a 2:1 dimetric cube instead (matching pixel-art game tiles), swap the parent
rotation to rotateX(60deg) rotateZ(-45deg) and drop the scale3d foreshortening
correction (dimetric game art is normally authored already at drawing scale) — but
say "2:1 dimetric" in your CSS class names and comments, not "isometric", per the
terminology rule.
A translateZ bump on hover reads as "lifting the cube toward the camera" because
the parent's rotateX/rotateZ already establishes the 3D basis — no extra math
needed, just animate along the face's own local Z:
.cube { transition: transform 200ms ease-out; }
.cube:hover { transform: rotateX(54.7356deg) rotateZ(-45deg)
scale3d(1.22474, 1.22474, 1.22474)
translateZ(20px); }
Because the translateZ(20px) is applied inside the already-rotated coordinate
system (it's the last term in the same transform value, composed after the
rotation), it moves the cube up the isometric camera's viewing axis rather than along
the screen's vertical — the lift reads as "toward the viewer," which is the effect
almost every isometric card/tile hover-state design wants (dashboards, game-tile
pickers, portfolio grids).
The scrollable "isometric floor" effect (Codrops "Isometric and 3D Grids", built with
Masonry-style layout underneath) is the same .iso-true transform applied to a large
flat <div> container whose children are normal in-flow 2D grid items (CSS Grid or
Masonry). Because preserve-3d propagates the parent's rotation to everything below
it, positioning the grid children is still ordinary 2D layout — only the outermost
container carries the isometric transform:
.iso-floor {
transform-style: preserve-3d;
transform: rotateX(54.7356deg) rotateZ(-45deg) scale3d(1.22474, 1.22474, 1.22474);
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
gap: 20px;
}
Scrolling the page (or a overflow: auto ancestor) still works normally because the
3D transform doesn't change the element's layout box for scroll purposes — only its
rendered appearance. This is the pattern behind most "isometric portfolio" and
"isometric dashboard" demos; see also references/svg-vector-generation.md for the
SVG-based equivalent used by JointJS-style diagram editors.
For a single flat card, icon tile, or UI panel where you don't need a real 3D
context (no per-face content, no perspective, no hover-into-depth), a pure 2D
rotate()/skew()/scale() composition is cheaper and simpler. This is the
.iso { transform: rotate(-30deg) skewX(30deg) scaleY(0.866); } family that shows up
across "CSS isometric card" snippets, and it is mathematically the same SSR
(Scale → Shear → Rotate) technique vector tools use — see
references/projection-math.md for the exact Illustrator SSR percentages this
mirrors.
Top-face recipe (verified, exact to true isometric):
.iso-top {
transform: rotate(-30deg) skewX(30deg) scaleY(0.86603);
transform-origin: center;
}
Mirror it for the opposite-handed top diamond:
.iso-top-mirror {
transform: rotate(30deg) skewX(-30deg) scaleY(0.86603);
}
Left/right face recipe (verified, exact — the skewY family):
.iso-left { transform: skewY(30deg); }
.iso-right { transform: skewY(-30deg); }
Composed together (a shared width/height rectangle) these three recipes produce a
correctly-proportioned isometric cube face-set from three separate flat 2D elements —
no preserve-3d, no 3D context, no perspective needed. This is the "disciplined 2D
transformation rather than full 3D rendering" pattern that a large share of
"isometric" browser work actually is (an observation echoed across the CSS-only iso
tutorial literature — Envato Tuts+, CSS-Tricks, FreeFrontend).
Don't take any of the above on faith — verify it yourself by tracking where the unit
basis vectors land. CSS composes a transform list left to right, each subsequent
function operating in the coordinate system already established by the previous
ones — equivalent to matrix-multiplying in the written order and applying the
product to a column vector: M = A · B · C, point p' = M·p.
Top-face check. For transform: rotate(-30deg) skewX(30deg) scaleY(0.86603),
composing M = R(−30°) · Skew_x(30°) · Scale(1, 0.86603) and applying it to the unit
basis vectors (screen coordinates, y-down, the CSS/SVG convention):
M · (1, 0)ᵀ = (0.8660, −0.5000)
M · (0, 1)ᵀ = (0.8660, 0.5000)
Both basis vectors land at the same 0.8660 horizontal run with a ±0.5 vertical rise —
exactly cos(30°) = 0.86603 horizontal and sin(30°) = 0.5 vertical, the two edges
of a diamond whose long axis is horizontal: this is the top-plane rhombus, the
same 2:1-looking (but exactly √3:1, true-iso) diamond you'd get slicing the top face
off the 3D cube in the previous section. If you re-derive this and get anything other
than (±0.866, ∓0.5)-family vectors, the recipe is wrong for true isometric — a
common broken variant is scaling by the visually-similar-but-wrong 0.864 (an
imprecise rounding that has propagated through several popular blog snippets) instead
of the exact 0.86603 = cos(30°); the axes then land a fraction of a degree off,
invisible at small sizes but compounding into visible drift on large grids or tiled
assets.
Left/right-face check. For transform: skewY(30deg):
M · (1, 0)ᵀ = (1, 0.5774)
M · (0, 1)ᵀ = (0, 1)
The vertical edge ((0,1)) stays exactly vertical — a left-face parallelogram keeps
its true-vertical edges vertical, which is what makes it read as the "side" of a cube
rather than another sloped diamond. The horizontal edge tilts by atan(0.5774) = 30°
— matching the top face's 30° edge exactly, so the two faces share a seamless edge
when butted together. 0.5774 = tan(30°) = 0.57735, the same constant family as the
Figma-hack height scale and the "top-plane circle → ellipse" ratio in
references/projection-math.md — not a coincidence; it's the same 30°-shear
identity showing up in every true-iso 2D-affine derivation. skewY(-30deg) mirrors
this for the right face (M · (1,0)ᵀ = (1, −0.5774)).
Run the numbers yourself before shipping a new recipe variant — any 2D affine
isometric transform must satisfy: (a) the shared edge between adjacent faces has
matching slope in both faces' bases, and (b) the diamond half-angle is exactly 30°
(true iso) or atan(0.5) = 26.565° (2:1 dimetric) — never eyeballed values like the
51deg/43deg "pragmatic dimetric" numbers seen in some minimal snippets (those
approximate the look of isometric but do not satisfy either projection's exact
angle and will not tile edge-to-edge with true-projection or 2:1-dimetric assets).
Prefer the CSS routes (either 3D or 2D-affine) over <canvas>/WebGL for:
aria-*
attributes, and semantic HTML (<button>, <a>, headings) work unmodified inside
an isometric-transformed container.preserve-3d face is still
selectable, searchable (Ctrl+F), and copyable; canvas-rendered text is not.:hover, :focus-visible, <input> elements, form
controls, and CSS :has()/media-query responsiveness all work without
reimplementing hit-testing.Prefer canvas/WebGL (see genart-ops for general three.js scaffolding,
references/threejs-orthographic.md for the iso-specific delta) once you need:
hundreds+ of independently-positioned tiles/sprites, per-pixel effects (lighting,
shadows, particle systems), or a real camera/scene graph for a game rather than a
static or lightly-interactive layout.
transform-origin defaults to 50% 50% (center) — fine for a single rotated
card, wrong for compositing cube faces around a shared pivot. When building a
multi-face cube from the 3D route, set an explicit transform-origin (or rely on
the translateZ half-extent trick shown above, which sidesteps the issue by
keeping every face's own origin at its own center and translating outward instead
of rotating around a shared point).skew() and non-90°-multiple rotate()
put text on a sub-pixel grid, and browsers do not always sub-pixel-hint
transformed text as crisply as untransformed text. Mitigations: increase source
font-size and scale down (renders at a higher effective resolution before the
blur-inducing transform), prefer font-smooth/-webkit-font-smoothing:
antialiased, or — for the 2D-affine route — apply the transform to a wrapper
<div> and keep a small counter-rotated/counter-skewed inner element for text
that must stay perfectly legible (accepting that it will look "flat" against the
tilted background).preserve-3d creates a genuine 3D
rendering context; elements at the same Z-depth (e.g. two faces both left at
translateZ(0)) render in DOM/paint order, not a deterministic depth-sort, so
overlapping same-depth elements can flicker or overlap unpredictably on
z-fighting-prone edges. Give every face a distinct translateZ (even a 1px
nudge) to force a stable order. Separately, preserve-3d breaks the moment
any ancestor introduces overflow, filter, opacity < 1, will-change,
mask, clip-path, contain, or perspective on itself (all of these force
their own new stacking context and flatten preserve-3d beneath them per the CSS
Transforms spec) — a filter: drop-shadow(...) added for a shadow effect on a
cube's wrapper is a common way to silently flatten the whole cube back to 2D.skewY(30deg) and its
siblings produce transcendental (non-integer) pixel offsets, two adjacent tiles
laid edge-to-edge in the 2D-affine route can show a hairline gap or overlap at
certain zoom levels due to sub-pixel rounding differences between the two
elements' independently-computed transform matrices. Mitigations: render tiles
from a single shared parent transform rather than per-tile transforms where
possible (fewer independent roundings), nudge overlapping edges by ~0.5–1px via
outline/negative margin as a defeat-device, or move to the 3D preserve-3d
route (a single parent transform, no per-face seam math) once seams become
visible at your target zoom/DPI.references/projection-math.md — the derivations behind every constant used here
(30°, 35.264°, 54.7356°, 86.602%, 57.735%, 1.22474), the Illustrator SSR method
this 2D route mirrors, and the full projection-decision table.references/svg-vector-generation.md — the SVG matrix() equivalent of these same
plane transforms, for when you need a <path>-based asset instead of a
transformed DOM element.references/coordinates-depth.md — tile↔screen math and draw-order/y-sort doctrine
for when CSS tiles become an actual game-like grid rather than a static layout.skills/genart-ops/SKILL.md — general three.js/creative-coding scaffolding
(canvas/WebGL route, out of scope here).transform-style: preserve-3d mandatory-on-children finding.rotateZ() almanac entry — rotateX(60deg) rotateZ(-45deg) dimetric
variant.QWvYoyY, "CSS 3D transforms: true isometric" — derivation of
rotateX(54.736deg) rotateZ(-45deg) scale3D(1.2247).rotateX(51deg) rotateZ(43deg)
pragmatic-dimetric hover-card recipe (cited here as the "eyeballed angle" anti-
pattern, not a recommended exact recipe).iso-pdf.md) — the .iso { transform: rotate(-30deg) skewX(30deg)
scaleY(0.866); } starter snippet and its framing as "disciplined 2D
transformation rather than full 3D rendering," alongside isometric-css
(see references/svg-vector-generation.md), Codrops IsometricGrids, and the
JointJS SVG guide.