3D space
The file opens with its own argument, and it is the right one to start from: 3D earns its place when depth carries meaning — a card that has two sides, a stack you are working down through, a control that physically depresses. It does not earn its place as decoration on a landing page.
When to use it
When the thing on screen genuinely has a third dimension. A card with a front and a back. A pile of records you deal off the top. A button that goes down when pressed.
In those cases the depth is not an effect, it is the model — and using it means the reader does not have to be told what will happen next. Everywhere else, 3D costs GPU memory, breaks in reduced motion, and makes text softer.
Everything in this layer uses rotate, scale and
translate as individual properties rather than the
transform shorthand. That is deliberate: two effects on the same element
compose instead of overwriting each other, so a .tilt inside a
.pile does not cancel the pile's offset. If you add your own, follow the
same rule.
Scene and space
Nothing looks three-dimensional without a vanishing point.
.scene establishes one; .space tells the children to stay in
it rather than being flattened.
.scene—perspective: var(--perspective, 1000px), with the vanishing point at--vanish, defaulting to the centre..scene-near— 560px. Stronger perspective, more dramatic..scene-far— 2000px. Nearly isometric..space—transform-style: preserve-3don a wrapper whose children need to keep their own depth..flat— the escape hatch, forcingtransform-style: flatback on for a subtree that should not..backface-hidden— hides an element's reverse side.
A smaller perspective means the viewer is closer, so rotation is more
extreme. If a flip looks violent, .scene-far is usually the fix rather than
a smaller rotation.
Flip card
Two faces in one grid cell, so the card is exactly as tall as its taller side and
nothing reflows when it turns. .is-flipped rotates it;
.flip-x turns it about the horizontal axis instead.
<div class="scene" style="max-inline-size:18rem">
<button type="button" class="flip flip-hover card" aria-label="Card, hover to see the back">
<span class="flip-front stack stack-1">
<strong>Standard</strong>
<span class="text-muted">£12 a month</span>
</span>
<span class="flip-back stack stack-1">
<strong>Includes</strong>
<span class="text-muted">5 seats, 20GB, email support</span>
</span>
</button>
</div>
<div class="scene" style="max-inline-size:18rem">
<button type="button" class="flip flip-hover card" aria-label="Card, hover to see the back">
<span class="flip-front stack stack-1">
<strong>Standard</strong>
<span class="text-muted">£12 a month</span>
</span>
<span class="flip-back stack stack-1">
<strong>Includes</strong>
<span class="text-muted">5 seats, 20GB, email support</span>
</span>
</button>
</div>
.flip-hover flips on hover, and it is wrapped in
@media (hover: hover) and (prefers-reduced-motion: no-preference) — so on a
touchscreen it does nothing at all, because a hover flip on touch would need a tap that
the card cannot distinguish from a press. On touch, toggle
.is-flipped from your own click handler.
Tilt
.tilt leans toward the pointer. deck.js writes
--tilt-axis and --tilt-amount from the cursor position, and
both are registered with @property as angles, so they interpolate smoothly
back to rest instead of jumping.
.tilt-lift on a child floats it above the card face at
--depth (28px by default); .tilt-lift-sm is 12px. Both only
apply where hover exists and motion is allowed.
Ships to production in about 40 seconds.
<div class="scene scene-near" style="max-inline-size:20rem">
<div class="tilt card stack stack-2" data-tilt="12">
<strong class="tilt-lift">Deploy</strong>
<p class="text-muted tilt-lift-sm">Ships to production in about 40 seconds.</p>
</div>
</div>
<div class="scene scene-near" style="max-inline-size:20rem">
<div class="tilt card stack stack-2" data-tilt="12">
<strong class="tilt-lift">Deploy</strong>
<p class="text-muted tilt-lift-sm">Ships to production in about 40 seconds.</p>
</div>
</div>
The script bails out entirely where (hover: hover) does not match, so no
pointer listeners are attached on a phone. It also re-checks reduced motion on every
move rather than only at startup, so changing the system setting takes effect without a
reload.
Pile
A stack of records you work down through. Each child's --i is its position
from the top, and everything follows from it: offset, scale, z-index and brightness are
all calc() on that one number.
Where sibling-index() is supported, --i comes from the browser
and the pile is any length. Where it is not, a @supports not block hard-codes
:nth-child for the first eight — so a pile of more than eight
cards has its ninth and later members all sitting at
--i: 0, stacked exactly on top of the first. That is a real limit,
not a rounding error.
<div class="pile" style="max-inline-size:20rem">
<div class="card stack stack-1"><strong>Invoice 4417</strong><span class="text-muted">£1,240 · due Friday</span></div>
<div class="card stack stack-1"><strong>Invoice 4418</strong><span class="text-muted">£860 · due Monday</span></div>
<div class="card stack stack-1"><strong>Invoice 4419</strong><span class="text-muted">£3,100 · due next week</span></div>
</div>
<div class="pile" style="max-inline-size:20rem">
<div class="card stack stack-1"><strong>Invoice 4417</strong><span class="text-muted">£1,240 · due Friday</span></div>
<div class="card stack stack-1"><strong>Invoice 4418</strong><span class="text-muted">£860 · due Monday</span></div>
<div class="card stack stack-1"><strong>Invoice 4419</strong><span class="text-muted">£3,100 · due next week</span></div>
</div>
.is-fanned on the pile spreads it out and brings the hidden cards back;
.is-dismissed on a child throws it off to the side and fades it, which is
what deck.js adds when a card is dealt away.
Coverflow
A horizontal scroller where off-centre items rotate away. The mechanics are scroll snap — real scrolling, real momentum, real keyboard scrolling — and the 3D is only the appearance.
deck.js marks the centred item .is-front and its neighbours
.is-behind-start / .is-behind-end. Without the script nothing
is marked, so every item simply sits flat and the component degrades to
a plain snapping scroller — which is the correct failure and is worth copying.
The scrollbar is hidden (scrollbar-width: none), and the container is not
focusable. That is the same keyboard gap recorded for Deck's other scroll containers in
FINDINGS.md: a keyboard-only user cannot scroll it without a focusable
child to tab to. Make the items focusable.
Cube
Six faces, positioned by translateZ at half the cube's size.
--size drives everything, so one value resizes the whole thing.
Which face is showing is an attribute, not a class:
data-face="front", "back", "start",
"end", "top" or "bottom". Note
start and end rather than left and right — the naming is logical even
though the transforms underneath cannot be.
<div class="scene" style="padding:2rem">
<div class="cube" data-face="front" style="--size:90px">
<span class="face-front">1</span>
<span class="face-end">2</span>
<span class="face-back">3</span>
<span class="face-start">4</span>
<span class="face-top">5</span>
<span class="face-bottom">6</span>
</div>
</div>
<div class="scene" style="padding:2rem">
<div class="cube" data-face="front" style="--size:90px">
<span class="face-front">1</span>
<span class="face-end">2</span>
<span class="face-back">3</span>
<span class="face-start">4</span>
<span class="face-top">5</span>
<span class="face-bottom">6</span>
</div>
</div>
.cube-spin rotates it continuously over 14 seconds. The whole rule —
animation and keyframes together — lives inside
@media (prefers-reduced-motion: no-preference), so under a reduced-motion
setting it does not merely slow down, it never starts.
Depressible buttons
.btn-3d gives a button real thickness with a solid
offset shadow. On :active the face moves down by
--lift and the shadow collapses to nothing, so the button
arrives at the surface rather than changing colour.
<span class="cluster">
<button type="button" class="btn btn-primary btn-3d">Deploy</button>
<button type="button" class="btn btn-primary btn-3d" style="--lift:6px">Deeper</button>
</span>
<span class="cluster">
<button type="button" class="btn btn-primary btn-3d">Deploy</button>
<button type="button" class="btn btn-primary btn-3d" style="--lift:6px">Deeper</button>
</span>
This is the clearest case for 3D in the whole layer: the depth is the feedback, it lasts 70ms, and it happens only in response to something the reader did.
Parallax, and page turns
.parallax is genuine 3D parallax rather than a scroll handler: layers sit
at different Z depths inside a container with an 8px perspective, and the compositor
does the arithmetic. There is no scroll listener, so there is nothing to
jank.
<div class="parallax">
<div class="parallax-layer parallax-back">…</div>
<div class="parallax-layer parallax-mid">…</div>
<div class="parallax-layer parallax-front">…</div>
</div>
The scale on each layer compensates for its depth, which is why
.parallax-back is scale(1.75) — without it a layer pushed 6px
away would render smaller.
.turn-out and .turn-in are a page-turn pair for stepping
through a wizard, meant to be used with Deck.transition() so the DOM swap
and the motion happen together. Both set transform-origin: left center and
both are flipped to right center under dir="rtl" in the same
file — one of the few places a physical value is corrected in place rather than in
src/19-logical.css.
.z-lift-1, -2, -3 and .z-sink move
an element 8, 20, 40 or −16 pixels toward or away from the viewer inside a scene. They
are for real depth in a 3D context, not a substitute for a shadow — outside a
.scene they do nothing visible.
Classes
These are nine separate effects in src/21-space3d.css rather than one
component root, so there is no completeness check on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.scene |
deck.effects |
A scene establishes the vanishing point. Children need preserve-3d to stay in the same space instead of being flattened. | 21-space3d.css:18 |
.scene-near |
deck.effects |
Sets --perspective. | 21-space3d.css:22 |
.scene-far |
deck.effects |
Sets --perspective. | 21-space3d.css:23 |
.space |
deck.effects |
Sets transform-style. | 21-space3d.css:24 |
.flat |
deck.effects |
Sets transform-style. | 21-space3d.css:25 |
.backface-hidden |
deck.effects |
Sets backface-visibility. | 21-space3d.css:26 |
.flip |
deck.effects |
Two faces, one footprint. Front and back are stacked in the same grid cell so the card is exactly as tall as its taller side. | 21-space3d.css:32 |
.flip-front |
deck.effects |
No declarations. | 21-space3d.css:37 |
.flip-back |
deck.effects |
No declarations. | 21-space3d.css:37 |
.flip-x |
deck.effects |
Sets transition. | 21-space3d.css:44 |
.flip-hover |
deck.effects |
No declarations. | 21-space3d.css:50 |
.is-flipped |
deck.effects |
No declarations. | 21-space3d.css:43 |
.tilt |
deck.effects |
deck.js writes --tilt-x and --tilt-y from the pointer position. Both are registered angles, so they interpolate smoothly back to rest. | 21-space3d.css:57 |
.tilt-lift |
deck.effects |
No declarations. | 21-space3d.css:67 |
.tilt-lift-sm |
deck.effects |
No declarations. | 21-space3d.css:68 |
.is-tilting |
deck.effects |
No declarations. | 21-space3d.css:64 |
.pile |
deck.effects |
Sets display, transform-style, perspective. | 21-space3d.css:89 |
.is-fanned |
deck.effects |
No declarations. | 21-space3d.css:108 |
.is-dismissed |
deck.effects |
No declarations. | 21-space3d.css:114 |
.coverflow |
deck.effects |
A horizontal scroller where the off-center items rotate away. Uses scroll snap for the mechanics and 3D only for the read. | 21-space3d.css:127 |
.is-front |
deck.effects |
No declarations. | 21-space3d.css:148 |
.is-behind-start |
deck.effects |
No declarations. | 21-space3d.css:146 |
.is-behind-end |
deck.effects |
No declarations. | 21-space3d.css:147 |
.cube |
deck.effects |
Six faces for a rotating status block or an empty-state illustration. | 21-space3d.css:153 |
.cube-spin |
deck.effects |
Sets animation. | 21-space3d.css:185 |
.face-front |
deck.effects |
No declarations. | 21-space3d.css:170 |
.face-back |
deck.effects |
No declarations. | 21-space3d.css:171 |
.face-start |
deck.effects |
No declarations. | 21-space3d.css:173 |
.face-end |
deck.effects |
No declarations. | 21-space3d.css:172 |
.face-top |
deck.effects |
No declarations. | 21-space3d.css:174 |
.face-bottom |
deck.effects |
No declarations. | 21-space3d.css:175 |
.btn-3d |
deck.effects |
A button with real thickness. The face moves down into the shadow instead of just changing color. | 21-space3d.css:196 |
.parallax |
deck.effects |
True 3D parallax: layers sit at different Z depths inside a perspective container, so the browser does the math on the compositor. No scroll handler, no jank. | 21-space3d.css:216 |
.parallax-layer |
deck.effects |
Sets position, inset, transform-origin. | 21-space3d.css:223 |
.parallax-back |
deck.effects |
Sets transform. | 21-space3d.css:228 |
.parallax-mid |
deck.effects |
Sets transform. | 21-space3d.css:229 |
.parallax-front |
deck.effects |
Sets transform. | 21-space3d.css:230 |
.turn-in |
deck.effects |
Sets animation, transform-origin. | 21-space3d.css:243 |
.turn-out |
deck.effects |
Sets animation, transform-origin. | 21-space3d.css:242 |
.z-lift-1 |
deck.effects |
Lift an element toward the viewer inside a scene, rather than faking it with a bigger shadow. | 21-space3d.css:257 |
.z-lift-2 |
deck.effects |
Sets transform. | 21-space3d.css:258 |
.z-lift-3 |
deck.effects |
Sets transform. | 21-space3d.css:259 |
.z-sink |
deck.effects |
Sets transform. | 21-space3d.css:260 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--ink-900 |
oklch(22.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral)) |
|
--space-4 |
1rem |
|
--space-6 |
1.5rem |
|
--dur-3 |
300ms |
|
--ease-spring |
linear(0, .18 6%, .53 14%, .89 24%, 1.04 32%, 1.06 40%, 1) |
Two tokens this layer uses are missing from that table. The flip is
--dur-4 (420ms) and the cube is --dur-5 (620ms), and neither
appears above because both are declared in src/16-motion.css rather than
src/01-tokens.css — and tools/docs/extract.mjs only reads the
latter. They are real tokens, they work, and the documentation cannot see them.
Recorded in FINDINGS.md.
--perspective, --vanish, --size,
--depth, --lift, --tilt-axis and
--tilt-amount are component-level properties rather than tokens. The last
two are registered with @property in src/00-layers.css, which
is what makes them animatable.
Accessibility
- A flip card hides half its content from everybody. Both faces are in the DOM and both are in the accessibility tree, so a screen reader reads the front and the back one after the other with no indication that they are two sides of one thing. A sighted reader sees one. Neither gets the whole picture. If the back holds information rather than a flourish, do not hide it behind a rotation.
-
If it flips, it must be a control. A
<div>with a click handler cannot be reached or operated by keyboard. Use a<button>, and give it a name that says what flipping does. -
.flip-hoveris unreachable by keyboard. It is a:hoverrule and there is no:focus-visiblealongside it. A keyboard user tabbing to the card sees nothing happen. Pair it with your own.is-flippedtoggle if the back matters. - Rotated text is harder to read. A tilted card renders its text through a 3D transform, which softens the rasterisation. At small sizes and low contrast that is a real legibility cost for a decorative gain.
-
A pile hides everything below the third card.
.pile > :nth-child(n+4)isopacity: 0andpointer-events: none— but notvisibility: hiddenorinert, so those cards are still in the tab order and still announced. A keyboard user can tab into a card nobody can see. Addinertto the hidden ones yourself. - Coverflow cannot be scrolled by keyboard alone. Hidden scrollbar, non-focusable container. Make the items focusable.
- 3D motion is the kind that causes nausea. Rotation and depth change are worse for vestibular disorders than fades or slides. This layer is unusually well guarded — see below — and the guards only work if you do not add unguarded motion beside them.
Right to left
Mostly handled, with one deliberate exception and one unavoidable one.
The cube's faces are named .face-start and .face-end rather
than left and right, which keeps the API logical — but the transforms behind them are
rotateY(90deg) and rotateY(-90deg), which are physical and do
not mirror. So data-face="start" shows the same physical face in both
directions. The name is logical, the geometry is not.
The page turn is corrected: [dir="rtl"] :is(.turn-out, .turn-in)
switches transform-origin to right center, so pages turn from
the correct edge — a book in Arabic opens the other way, and the animation follows.
Everything else — flip, tilt, pile, coverflow — is symmetrical about its own centre, so
there is nothing to mirror. The one thing to check is .pile > .is-dismissed,
which throws a card to translate: 120%, a physical direction. A dismissed
card flies right in both directions.
Reduced motion
This is the best-guarded file in Deck, and it is worth reading as a model rather than just a caveat. There are five separate provisions:
-
Hover effects are gated at the source.
.flip-hover,.tilt:hover,.tilt-liftand.tilt.is-tiltinglive inside@media (hover: hover) and (prefers-reduced-motion: no-preference)— so they are not overridden later, they are never defined. -
.cube-spinand the page turns are the same: the animation and its keyframes are both inside ano-preferenceblock, so nothing is declared to be switched off. -
The script checks too.
wireTiltcallsreduced()on every pointer move, not once at startup — so the setting can change mid-session. -
Parallax is dismantled rather than paused. Under
reduce,.parallaxloses its perspective and the layers becomeposition: relativewith no transform — the effect becomes an ordinary stacked document instead of a frozen 3D scene. -
The remaining transitions drop to 1ms and lose their rotation, except
the flip's end state, which is kept:
.flip.is-flippedstill rotates 180°. That is the right choice — the card still shows its other side, it just gets there instantly instead of turning.
The performance guard is worth noting alongside it: will-change is set only
on elements that are currently moving —
.tilt.is-tilting, .flip.is-flipped — and explicitly returned to
auto when they stop. 3D forces GPU layers, and a page that promotes
everything permanently will exhaust video memory on a modest device.
Printing
Nothing in src/21-space3d.css has a print rule, in either print layer. What
reaches paper is whatever each element's final transform left on screen: a flipped card
prints its back face mirrored, a tilted card prints tilted, and a cube prints as one
face with the others folded behind it.
A .pile is the worst case — three cards printed on top of each other, with
the rest at opacity: 0 and therefore invisible but still occupying the
page.
@media print {
/* Flatten everything and show what was hidden */
.flip, .flip-x, .tilt, .cube, .pile > *, .coverflow > *, .parallax-layer {
rotate: none !important;
scale: none !important;
translate: none !important;
transform: none !important;
opacity: 1 !important;
}
.pile { display: block; }
.flip > .flip-back { display: none; }
}
.pile { display: block } is the important line — it takes the cards out of
the single grid cell they share and lets them print one after another.
Overriding it
/* One scene, no layer needed */
<div class="scene" style="--perspective:700px;--vanish:30% 40%"> … </div>
@layer app.components {
/* A gentler tilt across the app */
.tilt { --depth: 16px; }
/* Focus parity for the hover flip */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.flip-hover:focus-visible { rotate: y 180deg; }
}
/* Make the cards a pile hides genuinely unreachable */
.pile > :nth-child(n+4) { visibility: hidden; }
.pile.is-fanned > :nth-child(n+4) { visibility: visible; }
}
When not to use it
- Not for decoration. The file says so in its first paragraph, and it is right. A tilting card on a marketing page costs GPU memory and legibility to say nothing.
- Not to hide content you need read. A flip card's back is invisible to sighted readers and announced out of context to everyone else. See Accessibility.
-
Not a pile of more than eight. Beyond eight, cards without
sibling-index()support all stack at position zero. - Not coverflow for a list people need to read. Rotated, scaled-down items are harder to scan than a list, and the container is not keyboard-scrollable.
- Not parallax on a page with a lot of text. Depth-scaled layers rasterise text softly, and the effect competes with reading.
-
Not
.z-lift-*as a shadow. Outside a.scenethey do nothing. Use Deck's elevation tokens.