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:
--distance: how many pixels from the camera to the scene's origin.
--fov: the vertical field of view, as a real angle. Wide is
dramatic; narrow is a long lens, flattening towards isometric.
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:
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.
<divclass="rotate"style="--ry: 90deg"><!-- turn to face right --><divclass="translate"style="--z: 100px"><!-- …then walk 100px that way --><divclass="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
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.
<divclass="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.
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.
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.