3-D

Unlisted: a proposal, not yet part of the framework.

CSS already has everything a scene graph needs; it just doesn't present itself as one. Depth is off by default, the camera is spelled as a property of somebody's parent, and position, rotation and scale are crammed into a single string that reads backwards. Wrapped up properly it becomes what nesting always promised: a scene, transform-only nodes, and faces that draw. A div inside a div means in 3-D exactly what it means everywhere else.

There are two modes, and they share everything except how they are positioned. A scene is a framed world with a camera, for when the 3-D is the content. A stage gives depth to a single element exactly where the document already put it, for when it isn't. Same nodes, same properties, same nesting.

The scene

The camera sits at zero, at the centre pixel of the scene, looking into the screen. You then say how far away the world is and how wide the lens is, the two things a camera actually has:

<div class="scene" style="--height: 380px; --fov: 45deg; --distance: 500px">
  <div class="view" style="--yaw: -28deg; --pitch: -18deg">
    …the world…
  </div>
</div>
Scene with a camera distance in pixels and a field of view in degrees

CSS doesn't offer either of those directly. Its perspective property is a length, really the distance from the eye to the z = 0 plane, the one plane that draws at 1∶1. So both dials are derived from it, and the scene does the arithmetic for you:

.scene {
  --persp: calc(var(--height) / 2 / tan(var(--fov) / 2));   /* the FOV, as a length */
  perspective: var(--persp);
}
.view {
  transform: translateZ(calc(var(--persp) - var(--distance)))   /* origin → --distance away */
             rotateX(var(--pitch)) rotateY(var(--yaw));
}
Deriving CSS perspective from a field of view and a camera distance

The scene's own height is the reference for the angle, so --height sets it rather than a plain height. Objects at the origin then draw at --persp ÷ --distance scale: push the world away and it shrinks, exactly as it should. Everything below the .view is measured in pixels from the origin, with +x right, +y down and +z towards you.

Two consequences worth knowing. Because the camera is at a finite distance, nothing may cross it: an object at --z greater than --distance is behind your eye, and CSS will tear rather than clip. And tan() in CSS needs a 2023-era browser: Chrome 111, Safari 15.4, Firefox 108.

Nodes

A node has no size and paints nothing. It exists only to move what's inside it, and it comes in one flavour per operation: .translate, .rotate, .scale. The point of splitting them is that nesting is the composition order: the inner transform happens first. So a .translate inside a .rotate reads as turn to face this way, then walk forward. The notorious question of which way round a transform list applies simply stops existing. You read it off the indentation.

<div class="rotate" style="--ry: 90deg">      <!-- turn to face right -->
  <div class="translate" style="--z: 100px">  <!-- …then walk 100px that way -->
    <div class="face">…</div>
  </div>
</div>
Nesting rotate then translate to compose order

When the ceremony isn't worth it, .node does all three on one element: translate, then rotate, then scale. Mix them freely; they compose identically.

Faces

A .face is the only thing in the graph with a size, and the only thing that draws. It's centred on its node, and --w and --h set its extent, which is in turn what constrains the ordinary HTML inside it. Six of them, each turned to face outwards and walked half a cube forward, is a cube:

Front

Right

Back

Left

Top

Bottom

Every face is the same recipe (turn, walk 90px, draw), which is how you'd build it in a 3-D engine, and now how you build it in HTML. The whole cube spins because one .rotate above them animates a single custom property:

@keyframes spin { to { --ry: 360deg; } }
.spin { animation: spin 20s linear infinite; }
Keyframes spinning a node via --ry

That works because --ry is registered with @property as a real <angle>, so the browser interpolates it per-axis. No matrix decomposition, no keyframing an opaque transform string.

Content in space

Here is the part no canvas can do. A face's content is just markup: it lays out normally, it stays selectable, focusable and clickable, and assistive technology still sees a form. The panel below is angled 30° in space; tab into it and type.

Sign in

Notes

Ordinary flow inside a face. The text wraps to --w, selects, and scrolls when it overruns.

Links still work. Focus rings still land in the right place. It is the document, seen from an angle.

A floor, and a reflection

A reflection is not a special effect. It's the same geometry, mirrored through the floor plane. In a scene graph that's one node with --sy: -1. Here the cube stands on the floor, its reflection hangs beneath, and the floor is a translucent plane laid over the top of it, so what you see through the floor is the mirrored cube.

Front

Right

Back

Left

Top

Bottom

The mirror

There are three objects in this scene and all three are real geometry: a cube, a second cube below the floor, and a floor. Nothing is a picture of anything.

The cube's centre sits 91px above the floor, half its height, so it stands on it, plus 1px of clearance so its bottom face isn't exactly coplanar with the floor and doesn't z-fight with it. The reflection is the identical subtree under a .scale node with --sy: -1, which flips the whole branch through the floor plane and puts the second cube 91px below it, upside down. Both cubes spin; the reflected one spins the mirrored way, because that's what mirroring a rotation does. The text in it reads backwards, which is what mirrors do too.

<div class="scale reflection" style="--sy: -1" aria-hidden="true" inert>
  …the same cube…
</div>
Mirroring a subtree through the floor plane with a negative Y scale

The reflected cube is not transparent. It is exactly as solid as the one above it: same faces, same colours, and its own back faces are properly hidden behind its front ones. Nothing about it is faded. It only looks faded because you are seeing it through a translucent floor, which is the only reason anything ever looks faded through glass. Make the reflection itself transparent and you can see its own back faces through its front ones, which is instantly, unmistakably wrong.

That's the rule the whole system runs on: objects are solid, surfaces are transparent. Fading is something a material in front does to you, never something you do to yourself.

The floor

The floor is one more object in the same space: a .face 2000px square, laid flat with --rx: 90deg. It is not an overlay and not a trick. It's a sheet of tinted glass lying in the y = 0 plane, and the reflection is genuinely underneath it, seen through it.

It's checkered so that it plainly reads as a surface rather than a haze. The checker is two alphas of one warm tint, not two colours (the more opaque squares hide more of the reflection, the sheerer ones hide less), so the pattern is legible in its own right and the cube beneath swims under it exactly as it would under real tile:

.floor {
  background:
    linear-gradient(to top,                 /* bottom = near, top = far */
      rgb(42 36 16 / 0)     0%,             /* clear — the reflection shows through */
      rgb(42 36 16 / 0)    28%,
      rgb(42 36 16 / 0.65) 45%,
      rgb(42 36 16 / 1)    58%),            /* opaque — dissolves into the background */
    repeating-conic-gradient(               /* the checker: two alphas of one warm tint */
      rgb(214 194 128 / 0.55) 0% 25%,
      rgb(214 194 128 / 0.30) 0% 50%)
      0 0 / 100px 100px;
}
A checkered floor: a two-alpha checker under a near-to-far fade

The floor is pushed back 200px so it spans z = +800 to z = -1200, which puts its near edge far below the bottom of the frame. Its far edge is a subtler problem: with a camera at a finite distance you cannot have an infinite floor: extend the plane past the camera and it tears. So the gradient on top of the checker ramps to a fully opaque --code-bg, and the floor dissolves into the scene background rather than ending in a visible horizon line.

Note what isn't here: no opacity on any node, no mask, no clipping, no second scene, and nothing translucent except the floor. The floor is see-through because its colour has an alpha channel, and that costs nothing at all. And because rotateX(90deg) maps the face's own local +y onto the world's +z, a plain to top gradient runs near-to-far across the floor for free.

One last thing worth saying out loud: the reflection is a duplicate of the DOM. Real elements, real headings, real focus targets, so aria-hidden="true" and inert are not optional. This is the one place where the great virtue of this approach, that the geometry is the document, bills you for it.

Moving the camera

Because the camera is just custom properties on a node, anything that can change a custom property can drive it, including the scroll position, with no script at all. Scroll this one through the viewport:

Left

--x: -150px

Centre

--z: 60px

Right

--x: 150px
.orbit-scene { view-timeline-name: --orbit; }
.orbit-scene .view {
  animation: orbit linear both;
  animation-timeline: --orbit;          /* the scene's own passage through the viewport */
}
@keyframes orbit { from { --yaw: -50deg; } to { --yaw: 50deg; } }
Scroll driven camera yaw via view timeline

A hover, a :checked box, a media query or two lines of JavaScript setting --yaw all work the same way. The scene graph doesn't care where the number comes from.

Flow mode

Everything above builds a scene: a framed viewport with a camera, an origin at the centre pixel, and a coordinate space that owes nothing to the document. That's right for a diorama (a cube, a floor, a carousel), where the scene is the content.

But most of the time you don't want a scene. You want one thing the document has already laid out, given depth where it stands. No camera to set up, no origin to reason about, and (the part that matters) with no transform set, it must render exactly where flow put it. You should never have to reverse-engineer a camera position just to get back to normal.

That's .stage. It wraps a single in-flow element and does nothing to it at all, except make depth available:

.stage {
  perspective: var(--distance, 1200px);   /* the camera, --distance from this element's plane */
  perspective-origin: 50% 50%;            /* the vanishing point IS the element's own centre */
}
.stage :where(.node, .translate, .rotate, .scale) {
  position: static;            /* stays exactly where the document put it */
  width: auto; height: auto;   /* keeps its own intrinsic size */
  transform-origin: 50% 50%;   /* turns about its own centre */
  transform-style: preserve-3d;
}
Flow mode: the same node classes, positioned by the document instead of a scene

Same node classes, same custom properties, same nesting. The only difference is that the document positions them instead of a scene. Two things fall out of that, and both are right rather than merely convenient. --fov has no meaning here (there is no frame for an angle to subtend), so flow mode takes only --distance, the honest eye-to-element distance. And .face disappears entirely: a face was only ever a stand-in for "the thing that has a size and draws", and in flow mode that's every element, because that is what flow does.

At rest, nothing happens

The two panels below are identical markup. The left one is wrapped in a .stage and is a .node; the right one is a plain div. Set no transform and there is nothing to tell them apart: same box, same size, same position, same baseline. That is the whole contract.

In a stage

Wrapped, depth-capable, untransformed, and therefore exactly where flow put it.

Not in a stage

An ordinary panel, for comparison. Identical rendering.

Tilt in place

Now set some properties. Hover or tab into the panel below. It turns about its own centre and lifts towards you. When it settles back to zero it lands precisely where it started, because zero is where it started.

Hover me

This panel is in the ordinary flow of the page. Nothing is absolutely positioned, nothing is a face, and there is no scene. It simply has depth available to it.

<div class="stage" style="--distance: 900px">
  <div class="node panel">…</div>
</div>

.panel { transition: --rx 0.35s, --ry 0.35s, --z 0.35s; }
.panel:hover,
.panel:focus-within { --ry: 10deg; --rx: -7deg; --z: 40px; }
A tilt-and-lift on hover: three custom properties and a transition

That's the entire effect. Because --rx, --ry and --z are registered with @property, they transition like any other property: no keyframes, no matrix interpolation, no JavaScript. And --z: 40px is a real translation towards the camera, so the panel genuinely grows as it approaches: perspective is doing the work, not a scale() pretending to.

A flip card

The classic, and it's four lines. One .rotate node with two sides inside it, each backface-visibility: hidden so it vanishes when turned away, and the back pre-turned 180° so it faces out when the card does. Hover or tab.

Front

Hover or focus to turn

Back

Still real, selectable content

<div class="stage" style="--distance: 700px">
  <div class="rotate flip">
    <div class="side front">…</div>
    <div class="side back">…</div>
  </div>
</div>

.flip       { position: relative; transition: --ry 0.6s ease; }
.flip:hover { --ry: 180deg; }
.side       { position: absolute; inset: 0; backface-visibility: hidden; }
.back       { transform: rotateY(180deg); }
A flip card in flow: one rotate node, two back-to-back sides

Note that backface-visibility is doing real work here: it's the one piece of genuine 3-D rendering that has no 2-D equivalent, and it's why you don't need to fade, hide or z-index anything. The side facing away is simply not drawn.

Which mode?

Use a scene when the thing you're building has its own space and its own camera, when "where is the viewer standing" is a question you want to answer, and when objects need to relate to each other in depth. Cubes, floors, reflections, carousels, dioramas, product spins.

Use a stage when the document is still in charge of layout and you just want depth as a finishing property, the way you'd use a shadow. Tilting cards, flips, hinged panels, unfolding menus, page turns, anything that must sit in ordinary flow and be pixel-identical at rest.

They're not exclusive: a .face inside a scene can contain a .stage, because the 3-D graph ends at a face and inside one you are back in ordinary CSS. A panel on a wall in a scene can have a card on it that flips.

The one trap

Depth is not a property of an element, it's a property of an unbroken chain of them. A handful of innocent-looking declarations break the chain. overflow other than visible, opacity below 1, filter, clip-path, mask, mix-blend-mode and contain: paint each force the element that carries them to flatten everything inside it into a single plane.

In practice that means one rule: put those on a face, never on a node above one. Fading a panel out is opacity on the .face; put it on the .rotate and the subtree quietly collapses. The failure looks almost right, which is the worst way for anything to fail.

The reflection is the worked example of the way out. You might reach for opacity on the mirror node to fade it: that flattens the reflected cube to a smear. You might then reach for opacity on its faces instead, which is legal, since a face is a leaf, but that makes the cube itself transparent and you see its own back faces through its front ones. The answer was neither: leave the object solid and put the transparency in the floor. Almost every time you want to reach for opacity in a scene, what you actually want is alpha in a surface.

The other rough edges are honest limits rather than traps. Faces are flat rectangles: there are no meshes and no curves except by faceting. There is no lighting model: shading is something you author per face. Coplanar faces z-fight, and the fix is a manual --z: 0.01px nudge. And the cost is one compositor layer per face, so this is a technique for tens of faces, not thousands.

Within those limits: real perspective, correct depth sorting, faces that intersect properly, backface culling, near-orthographic projection by winding --fov down and --distance up, and content that is still the document.