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-3d on a wrapper whose children need to keep their own depth.
  • .flat — the escape hatch, forcing transform-style: flat back 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.

A real button, so it is reachable — but see Accessibility before shipping this
<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.

data-tilt sets the maximum angle; 9 degrees is the default
Deploy

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.

Only the top three are visible — the fourth onward are opacity 0 until fanned
Invoice 4417£1,240 · due Friday
Invoice 4418£860 · due Monday
Invoice 4419£3,100 · due next week
<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.

Change data-face and it rotates to that side
1 2 3 4 5 6
<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.

Press and hold to see the face meet the shadow
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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-hover is unreachable by keyboard. It is a :hover rule and there is no :focus-visible alongside it. A keyboard user tabbing to the card sees nothing happen. Pair it with your own .is-flipped toggle 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) is opacity: 0 and pointer-events: none — but not visibility: hidden or inert, so those cards are still in the tab order and still announced. A keyboard user can tab into a card nobody can see. Add inert to 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-lift and .tilt.is-tilting live inside @media (hover: hover) and (prefers-reduced-motion: no-preference) — so they are not overridden later, they are never defined.
  • .cube-spin and the page turns are the same: the animation and its keyframes are both inside a no-preference block, so nothing is declared to be switched off.
  • The script checks too. wireTilt calls reduced() on every pointer move, not once at startup — so the setting can change mid-session.
  • Parallax is dismantled rather than paused. Under reduce, .parallax loses its perspective and the layers become position: relative with 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-flipped still 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 .scene they do nothing. Use Deck's elevation tokens.