Tilt Card

A card that leans toward the cursor, then settles.

← All motions

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.

trigger: cursor perspective + rotate3d bounds-mapped, 8° max

Live demo

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.

Activation +34% · support tickets halved

The prompt

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.

What actually makes it work

Compatibility and performance

AspectDetail
3D transformsUniversal, including older Safari with the -webkit- prefix on perspective.
CSS custom propertiesUniversal. Updating a custom property that only a transform reads does not invalidate layout.
CostComposited transform per move event. One card is cheap; forty cards each with their own listener is not.
Blur and shadowBoth 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.

When not to use it

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.