Case study
Rebuilding onboarding in six weeks
A tilt of eight degrees is enough to read as depth. Past twelve it reads as a toy, and the text starts to look like it is sliding off the card.
A card that leans toward the cursor, then settles.
Tilt Card
Tilt is the effect that most reliably separates a demo from something shippable. The rotation itself is two lines; the work is in the bounds, the transition timing, the glare, and knowing to switch the whole thing off on a touchscreen and for anyone who has asked for less motion.
Case study
A tilt of eight degrees is enough to read as depth. Past twelve it reads as a toy, and the text starts to look like it is sliding off the card.
Create a card that tilts in three dimensions as the pointer moves across it.
Structure:
- A wrapper element with perspective: 900px. The perspective belongs on the parent, not the card; putting it on the card makes every card compute its own vanishing point.
- The card itself is the element that rotates, and it must have transform-style: preserve-3d if any child should sit on its own plane.
- A glare element absolutely positioned over the card, radial-gradient, mix-blend-mode: screen, pointer-events: none.
Behaviour:
- On pointermove, compute normalised coordinates: x = (clientX - rect.left) / rect.width - 0.5, giving a range of -0.5 to 0.5. Same for y.
- Rotate by a maximum of 8 degrees: rotateY(x * 16deg) and rotateX(y * -16deg). The sign inversion is what makes the card lean toward the cursor rather than away.
- Cache the bounding rect on pointerenter and on resize. Do not call getBoundingClientRect inside pointermove; that is the layout read that makes tilt stutter.
- While the pointer is over the card, update the transform with no transition. On pointerleave, apply a 400ms ease-out transition back to identity, then remove the transition after it finishes so the next entry is immediate.
- Move the glare with the same coordinates via two CSS custom properties, not by setting background-position.
Constraints:
- Only run on devices with a fine pointer: guard with matchMedia('(hover: hover) and (pointer: fine)').
- Under prefers-reduced-motion: reduce, do not rotate at all. Keep the glare static or remove it.
- Maximum rotation 8 to 10 degrees. Larger angles distort text and break the illusion.
- The card must remain fully readable at maximum tilt; do not let 3D transforms push content outside the card bounds.
Do not:
- Do not animate box-shadow or filter alongside the transform; both run on the main thread and will drop frames.
- Do not tilt every card in a grid at once. Tilt only the card under the pointer.
perspective defines the distance between the viewer and the plane. On the parent, all children share one vanishing point; on each card, every card gets its own, and a grid of them looks incoherent.getBoundingClientRect() inside pointermove forces a layout read on every move event, which can fire several times per frame. Reading it once on enter and on resize removes the entire class of stutter this effect is known for.(hover: hover) and (pointer: fine) is the correct guard.| Aspect | Detail |
|---|---|
| 3D transforms | Universal, including older Safari with the -webkit- prefix on perspective. |
| CSS custom properties | Universal. Updating a custom property that only a transform reads does not invalidate layout. |
| Cost | Composited transform per move event. One card is cheap; forty cards each with their own listener is not. |
| Blur and shadow | Both are main-thread effects. A large box-shadow that changes with the tilt will visibly lag behind the rotation. |
One practical note: text inside a rotated element is resampled by the compositor and looks slightly softer at the extremes of the tilt. If the card contains small type, keep the maximum angle lower or accept a small loss of sharpness at the edges.
Don't use it on anything you have to read carefully. Text on a moving plane is measurably harder to read, and tilt is at its worst on the exact cards that carry the most copy.
Don't apply it to a whole grid. If every card tilts as the pointer crosses the page, the layout looks unstable rather than responsive. One focal card, or none.
Don't combine it with a hover lift and a shadow change. Three depth cues at once contradict each other, and the card looks like it is vibrating rather than responding.