3D scenes
Three recipes using real CSS 3D transforms: a phone-or-card-like object built from plain rectangles that turns to show its back, cards that flip around their vertical axis, and image layers that cross a pinned stage at different speeds for a sense of depth.
CSS 3D (preserve-3d, backface-visibility) renders flat in some automated test browsers, even
when it's correct. Always check real 3D effects in actual Chrome, Firefox or Safari, not just an
automated test run.
3D object with real thickness
A phone, card or box made entirely of plain blocks (front, back, edges, rounded corners) that turns in genuine 3D as the page scrolls — no images of the object itself required. A typical example: a phone that spins from its back to its front, then tilts to face each feature as the visitor scrolls past.
Structure (inside a pinned scene; the front face must be the first child and the back face the second — the edge geometry depends on that order):
div .tour the only animated element — "Get parent" + Custom, Start 20, End 80
└─ div .device perspective; overall size of the object
└─ div .device__spin preserve-3d; turns back-to-front at the very start of the scene
└─ div .device__body preserve-3d; does the tilting; position: relative
├─ div .device__front child 1
├─ div .device__back child 2
├─ div .device__side ×4 children 3–6: the four straight edges
├─ div .device__corner ×36 children 7–42: 9 strips per rounded corner
└─ div .device__core last child
The core CSS pattern:
.tour {
--dv-w: min(17rem, 34vh);
--dv-spin: clamp(0, var(--fa-scroll--custom, 1) * 6, 1); /* turns from back to front in the first sixth of the row */
--dv-a: -35deg; /* the tilt angle — any expression of the scroll progress */
}
.device { width: var(--dv-w); aspect-ratio: 1 / 2.05; position: relative; perspective: calc(var(--dv-w) * 6); }
.device__spin { width: 100%; height: 100%; transform-style: preserve-3d; rotate: y calc((1 - var(--dv-spin, 1)) * -180deg); }
.device__body {
width: 100%; height: 100%; position: relative; transform-style: preserve-3d; rotate: y var(--dv-a, 0deg);
--dv-t: calc(var(--dv-w) * .09); /* thickness */
--dv-te: calc(var(--dv-t) + 2px); /* edges slightly thicker, so they tuck behind each face */
--dv-r: calc(var(--dv-w) * .16); /* corner radius */
}
.device__front, .device__back, .device__core { position: absolute; inset: 0; border-radius: var(--dv-r); }
.device__front, .device__back { backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.device__front { transform: translateZ(calc(var(--dv-t) / 2)); background: #141417; }
.device__back { transform: rotateY(180deg) translateZ(calc(var(--dv-t) / 2)); background: #2a2a2f; }
The full build (all four straight edges, all 36 corner strips with their individual rotation angles, and the "tilt towards each feature in sync with a sequence" variant) is a long, precise piece of trigonometry-driven CSS — each corner strip differs only by its own angle and a pair of sign flips, so it's mechanical to generate once you have the pattern, but long enough that reproducing all 36 rules here wouldn't be a good use of this page. The parts worth knowing up front:
- 36 corner strips, 9 per rounded corner at 10° increments, each with its own
left/top/rotateZderived from a single formula (only the sign and the angle change between them) — flat strips alone leave visible gaps, and fewer than 9 per corner looks faceted rather than round. - Children must be ordered exactly: front (1), back (2), the 4 sides (3–6), the 36 corner strips
(7–42), then a solid core as the very last child — nothing should shift the
:nth-childrules meant for the corners. - Tilting towards a feature uses the same "which item is centred, continuous" math as the sequence recipe: the object's tilt angle becomes a function of which feature caption is currently centred, rather than snapping between fixed angles.
Values you can change: --dv-w (overall size), the thickness (.09 × width) and corner
radius (.16 × width) ratios, the perspective multiplier (lower = a stronger, more dramatic 3D
effect), face/edge colours, and how much of the scroll row the initial spin takes (* 6 = the
first sixth).
- Bricks
- Etch
- Tour: "Get parent" + one Custom row, Start 20, End 80. Nothing inside the object itself needs any Fancy settings — only the single outer driver does.
- Every empty piece (sides, corners, core) needs an explicit minimum width/height of 0 (and outline none) — the builder otherwise gives empty blocks a 90×90px placeholder, which deforms the object in the canvas only (not on the front end).
- Tour classes:
fa--parent fa--custom fa--custom--start-20 fa--custom--end-80 fa--smooth-10(give any feature captions the same smoothing value). - The canvas shows empty elements as placeholder boxes — set the canvas-only empty-element size
variable to 0 on
.device__body's class so it doesn't distort the shape while editing.
Common problems:
- The object turns completely flat, with no sense of depth (on sites before 3.1.1). Older
versions kept a
filter: opacity(1)on every animated element, and any CSS filter flattenspreserve-3d— make sure no Fancy class sits between.deviceand the faces; only the single outer driver computing the variables should carry one. This is fixed from 3.1.1 onward. - Visible gaps, or a faceted rather than round look, at the corners. Too few strips per corner — use 9 per corner, every 10°, slightly overlapping, not 3.
- Thin light "seams" visible in Safari and Firefox. Anti-aliasing on the edges lets the page show through — make the edges 2px thicker than the body itself, with a solid dark core as the very last child.
- A straight edge disappears entirely in Chrome. Chrome doesn't paint a
background-imageon a tall, narrow element rotated 90° — put the gradient on a::beforepseudo-element inside the edge strip instead. - 3D looks completely flat during automated testing. Some headless test browsers render
preserve-3dflat even when it's correct — check in a real browser.
3D flip cards
Three cards slide and turn into place, then flip around their vertical axis to reveal their back — all driven by the scroll, inside a pinned stage.
Structure (a 200vh pinned scene, pinned 33–67%):
div .pin__stage
└─ div .flip__grid 3 columns (1 on phones)
└─ div .flip side cards: "Get parent" + Custom 35–45, Inverted (moves into place)
├─ div .flip__front "Get parent" + Custom 50–60
│ └─ div .flip__depth > content
└─ div .flip__back "Get parent" + Custom 50–60, Inverted
└─ div .flip__depth > content
Each card carries two separate scroll windows on purpose: the card's own row moves it into place, and the two faces' own rows handle the flip. A child with its own Custom row always reads its own progress, not its parent's.
CSS:
.flip {
position: relative; min-height: 450px;
transform-style: preserve-3d; perspective: 1000px;
}
.flip:nth-child(1) { transform: translateX(calc(var(--fa-scroll--custom, 0) * 60%)) rotate(calc(var(--fa-scroll--custom, 0) * -15deg)); }
.flip:nth-child(3) { transform: translateX(calc(var(--fa-scroll--custom, 0) * -60%)) rotate(calc(var(--fa-scroll--custom, 0) * 15deg)); }
.flip__front, .flip__back {
transform-style: preserve-3d;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
border-radius: 1rem; padding: 1.5rem;
display: flex; flex-direction: column; justify-content: center; align-items: center;
}
.flip__front {
position: absolute; inset: 0; background: #fff;
transform: rotateY(calc(var(--fa-scroll--custom, 0) * -180deg));
}
.flip__back {
position: relative; height: 100%; z-index: -1; background: #27272a; color: #fff;
transform: rotateY(calc(var(--fa-scroll--custom, 0) * 180deg));
}
.flip__depth { transform: translateZ(80px) scale(.9); }
The back face's row is inverted (runs 1 → 0), so it starts turned away at 180° and ends facing the visitor while the front turns from 0° to -180° at the same time. The middle card has no row of its own at all — it's already in its final place.
Values you can change: the two windows (move-into-place 35–45, flip 50–60 — keep both inside
the pinned window), perspective (lower = stronger 3D), translateZ(80px) on .flip__depth (how
far the content floats above each face), and the side cards' offset (60%) and tilt (15deg).
- Bricks
- Etch
- Side cards: "Get parent" + Custom, Start 35, End 45, Inverted.
- Front: Custom, Start 50, End 60. Back: the same, also Inverted. Both "Get parent".
- "Disable from mobile" on cards and faces, and lay the faces out flat (stacked, not flipped) below 768px.
- Side cards:
fa--parent fa--custom fa--custom--start-35 fa--custom--end-45 fa--custom--inv. - Front:
fa--parent fa--custom fa--custom--start-50 fa--custom--end-60; back: the same plusfa--custom--inv. - Phones:
fa--no-mobileon cards and faces, with the flat layout nested in@media (max-width: 767px).
Common problems:
- Both faces are visible at once, or the text reads mirrored.
backface-visibility: hidden(with the-webkit-prefix for Safari) is missing on one of the faces. - No sense of depth on the content — the faces look flat (sites before 3.1.1). The same
filter: opacity(1)issue as the 3D object recipe above — this needs 3.1.1; the flip itself works fine on 3.0 without the depth layer. - The flip plays while the card is still sliding into place. The windows for the card's own movement and its faces' flip overlap — leave a gap between them (e.g. 45 → 50).
- 3D looks flat during automated testing. Check in a real browser instead.
Parallax layers
Two uses of the same underlying idea: the same scroll window, applied at different distances, reads as depth.
A. Layers crossing a pinned stage
Inside a pinned scene (a 300vh scene, pinned 25–75%), a centred heading stays still while two grids of images rise up past it — a "front" layer and a "back" layer, the back one slower, smaller in contrast and positioned behind the heading.
div .pin__stage align-items: center
├─ div .layers__content the heading, z-index 2
├─ div .layers__front "Get parent" + Custom, Start 10, End 70, From 5
│ └─ img .layers__image …
└─ div .layers__back "Get parent" + Custom, End 70
└─ img .layers__image …
.layers__content { position: relative; z-index: 2; text-align: center; }
.layers__front, .layers__back {
position: absolute; top: 0;
display: grid; grid-template-columns: 1fr 1fr;
}
.layers__front { z-index: 1; width: calc(100% - 3rem);
transform: translateY(calc(85% + var(--fa-scroll--custom) * -180%)); }
.layers__back { z-index: -1; width: 70%; opacity: .4;
transform: translateY(calc(85% + var(--fa-scroll--custom) * -120%)); }
.layers__image { width: 100%; max-width: 23vw; aspect-ratio: 3 / 4; object-fit: cover; }
.layers__image:nth-child(even) { margin-left: auto; }
Both layers start 85% of their own height below their top edge; the front layer travels 180% of its height, the back one only 120% — the gap between those two numbers is what reads as depth. Stagger the images with alternating margins so the grid doesn't look too regular.
B. A background image that drifts inside a normal section
section .bg-parallax "I am parent", position: relative
├─ div .bg-parallax__content
└─ div .bg-parallax__window absolute, inset 0, overflow hidden, z-index -1
└─ img .bg-parallax__image "Get parent" + Custom (no Start/End)
.bg-parallax { position: relative; z-index: 1; }
.bg-parallax__window { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
.bg-parallax__image {
position: absolute; left: 0; width: 100%; height: 200%;
object-fit: cover; border-radius: 0;
top: calc(var(--fa-scroll--custom, 0) * -100%);
}
The image is twice the section's own height and moves up by exactly one section height while the
section crosses the screen. For the opposite direction, swap top/bottom:
top: auto; bottom: calc(var(--fa-scroll--custom, 0) * -100%);.
Values you can change: A — the two travel percentages (a bigger gap between them reads as more
depth), the back layer's opacity and width. B — the extra height (200%, paired with -100%
travel) and the travel distance (keep the two matched, or a gap appears at the top or bottom).
- Bricks
- Etch
- A: front and back layers each get "Get parent" + their own Custom row with the windows above.
- B: the section gets "I am parent"; the image gets "Get parent" + a Custom row with no window, plus native Object fit "cover" and border radius 0.
- Rotation values belong in custom CSS, not the native transform control's rotate fields — those
don't keep
calc()expressions.
- A: front
fa--parent fa--custom fa--custom--start-10 fa--custom--end-70 fa--custom--from-5; backfa--parent fa--custom fa--custom--end-70. - B: section
fa--iparent; imagefa--parent fa--custom.
Common problems:
- Only the front layer is visible — no sense of depth at all. The layers are opaque full-bleed photos, so the front one simply covers the back one. Use images with real transparency, or mask each nearer layer so only its lower portion shows through.
- A hero title doesn't move with the scroll at all. It has an entrance animation instead, which plays once on load and then stops — give its wrapper a Custom row inside the pinned window instead.
- A rounded or letterboxed background (variant B). Many themes round every
imgby default — setborder-radius: 0andobject-fit: coverdirectly on the image. - The layer doesn't move at all. No "Get parent", or no "I am parent" above it.
- A preset on the same element cancels the transform. Zoom, Rotate and the slide presets also
write
transform— keep one transform source per element.