Every element on a web page sits on a third axis you can't see. x runs across the screen, y runs down it, and the z axis runs straight out of the screen towards your eye. Moving an element along z is how you make it approach the viewer or recede away from them.
Here's the catch: on its own, it does nothing. Set translateZ(200px) on a div and it will look exactly as it did before. There's no camera in the scene, so there's no vanishing point, and with no vanishing point the browser has no reason to draw a nearer object any bigger. The axis everyone comes looking for is the one that's invisible until you ask for it.
The thing you ask for is perspective.
The three axes
Positive x moves right, positive y moves down (not up, which catches out anyone arriving from a graphics background), and positive z moves towards the viewer.
Rotations are named for the axis they turn around, not the direction they travel:
-
rotateXtips the element forwards and backwards, like a lid closing. -
rotateYswings it like a door on a vertical hinge. -
rotateZis the flat spin you already know. It's identical torotate.
rotateZ is the only one of the three that looks like anything without a camera, because it never leaves the plane of the screen. The other two do leave it, and the browser flattens what it can't project. Without perspective, rotateY(60deg) is indistinguishable from scaleX(0.5): the element just squashes horizontally and the far edge never gets any smaller.
So that's the whole problem. Let's give the scene a camera.
Perspective is the camera
perspective is a distance: how far the viewer is from the z=0 plane. Everything else follows from it. Small values put the camera close to the page, which exaggerates the effect into a fisheye. Large values pull it back into a telephoto lens, where even a big rotation stays subtle. Somewhere around 800px to 1200px reads as natural for card-sized UI, and anything under 400px starts to look deliberately dramatic.
There are two places to set it, and they behave differently.
Perspective on a parent
The perspective property goes on a parent and applies to that parent's transformed children:
.scene {
perspective: 800px;
}
Every child of .scene now shares a single vanishing point, sat at the centre of .scene. Put three cards side by side and the left one leans right, the right one leans left, and the middle one faces you head on, exactly as if you'd photographed all three together. This is what you want for a scene: a shelf of cards, a carousel, a cube.
Two side effects worth knowing about, because they bite later: perspective creates a new stacking context, and the element becomes a containing block for any position: fixed descendants.
Perspective on the element itself
The perspective() transform function goes in the element's own transform, and gives that element its own vanishing point at its own centre:
.card {
transform: perspective(800px) rotateY(30deg);
}
Three cards styled like this each look photographed straight on, independently. That's the wrong look for a shared scene and precisely the right look for an isolated widget, like a card that tilts under the pointer. Motion exposes this as the transformPerspective value, so you can animate it:
animate(".card", { transformPerspective: 500, rotateY: 30 })
Order matters if you're hand-writing the CSS. Transform functions compose right to left, so anything you write to the left of a rotation is applied after it. Put perspective() at the end of the list and you're rotating the projection rather than projecting the rotation, which looks wrong in a way that's hard to diagnose. Motion always serialises transformPerspective first, so it's one less thing to hold in your head.
Moving the vanishing point
perspective-origin decides where the camera is pointing. It defaults to 50% 50%, the centre of the element:
.scene {
perspective: 800px;
perspective-origin: 50% 0%;
}
Move it to the top and the viewer is now above the scene, looking down at it. This is the cheapest way to make a row of cards feel like it's laid out on a table rather than pinned to a wall.
One more consequence of treating perspective as a real distance: push an element further along z than your perspective value and it passes the camera. The parts of it that end up behind the viewer aren't drawn at all.
Rotating in 3D
With a camera in place, rotateX and rotateY finally do what you expect. The canonical use for them is the card flip, and it's worth building once because every 3D pattern is a variation on it.
A flip needs two faces occupying the same space, with the back one pre-rotated so that it's facing away from you at rest:
<div class="scene">
<div class="card">
<div class="face front">Front</div>
<div class="face back">Back</div>
</div>
</div>
.scene {
perspective: 1000px;
}
.card {
position: relative;
transform-style: preserve-3d;
}
.face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.back {
transform: rotateY(180deg);
}
backface-visibility: hidden is what makes this work. By default the browser happily draws the reverse of an element when it turns away from you, mirrored. Hide both backfaces and only the face currently pointing at you gets drawn, so rotating the card 180 degrees swaps one for the other. It has no effect on 2D transforms, since nothing there ever turns away.
Then the animation is one value:
import { animate, press } from "motion"
let flipped = false
press(".card", (card) => {
flipped = !flipped
animate(
card,
{ rotateY: flipped ? 180 : 0 },
{ type: "spring", visualDuration: 0.4, bounce: 0.2 },
)
})
Nesting scenes with preserve-3d
transform-style defaults to flat, which means a parent flattens its children onto its own plane before applying its own transform. Any z position a child had is thrown away in the process. That's fine for a single rotating element and fatal for anything built out of parts.
transform-style: preserve-3d keeps the children in the shared 3D space instead, so they each hold their own position and rotation relative to the scene. It's what lets the two faces of the card above stay back to back instead of collapsing into one plane, and it's the only way to build something like a cube out of six divs.
A 3D rendering context extends as far as the chain of preserve-3d goes. Break the chain at any level and everything below it flattens onto that element's plane.
What silently flattens a scene
This is where 3D scenes go to die, so it's worth knowing the list. Some CSS properties require the browser to render the subtree into a single flattened image before it can apply them. When one of those is set, the used value of transform-style becomes flat no matter what you wrote. The spec calls these grouping property values, and they are:
-
overflow: any value other thanvisibleorclip -
opacity: any value less than1 -
filter: any value other thannone -
clip-path: any value other thannone -
mask-image: any value other thannone -
mix-blend-mode: any value other thannormal -
isolation: isolate -
Anything that causes paint containment, like
contain: paintorcontent-visibility: hidden
overflow: hidden on a card wrapper is far and away the most common culprit, usually added months earlier to clip a rounded corner. The opacity entry is the sneaky one, because it applies mid-animation too: fade a 3D scene in and the scene is flat for the whole fade, then pops into depth as opacity reaches 1.
3D in Motion
Motion animates every transform axis as an independent value, so nothing here needs a hand-built transform string. The 3D ones are z, rotateX, rotateY, rotateZ and transformPerspective, alongside the full transform list that animate supports.
Independence is the useful part. Each value animates on its own timeline, so a card can spring its rotation while its z tweens on a duration, and a pointer can drive rotateX while a scroll drives rotateY, with neither overwriting the other:
import { animate } from "motion"
animate(
".card",
{ rotateX: 12, z: -40 },
{ type: "spring", visualDuration: 0.5, bounce: 0.25 },
)
Rotation is where springs earn their keep. A flip that overshoots a few degrees and settles reads as a physical object with weight; the same flip on a fixed duration reads as a slideshow.
For a hover tilt, feed the pointer's position within the element straight into two rotations. hover handles the listener lifecycle and filters out the fake hover events touch devices emit, and the callback returns its own cleanup:
import { animate, hover } from "motion"
const maxTilt = 15
hover(".card", (card) => {
const onMove = (event) => {
const rect = card.getBoundingClientRect()
const x = (event.clientX - rect.left) / rect.width
const y = (event.clientY - rect.top) / rect.height
animate(card, {
transformPerspective: 500,
rotateX: maxTilt * (0.5 - y),
rotateY: maxTilt * (x - 0.5),
})
}
card.addEventListener("pointermove", onMove)
return () => {
card.removeEventListener("pointermove", onMove)
animate(card, { rotateX: 0, rotateY: 0 })
}
})
Note the transformPerspective rather than a perspective on the parent. A tilt card is an isolated widget, so it wants its own vanishing point at its own centre, not one shared with its siblings.
The same values make good entrances. A dialog that arrives from z: -100 with a small rotateY feels like it's moving through space rather than sliding on a pane of glass, and it costs one extra property:
A note on performance
Transforms are compositor properties, and that includes the 3D ones. Animating rotateY triggers neither layout nor paint, which puts it in the same cheap bracket as opacity and 2D transform. There's more on why in the performance guide.
The cost is elsewhere. To sort elements in 3D space the browser has to keep them as separate composited layers, so establishing a 3D context tends to promote everything taking part in it. That's the same mechanism behind the old translateZ(0) promotion trick, and it isn't free: every layer holds a texture in GPU memory, sized to the element. A handful of cards is nothing. A grid of two hundred tilting tiles, each promoted for the whole time the page is open, is a different conversation, and low-powered devices notice first.
So reach for a 3D context deliberately rather than by default, and keep it scoped to the elements that actually move. If you want to see what you've created rather than guess, Chrome DevTools has a Layers panel that shows every composited layer on the page and its memory estimate.