Anatomy of Tilt Card
How one spring-smoothed pointer value drives a card's rotation, its floating content, and a glare that chases the cursor.
Tilt Card doesn't animate a rotation and a glare separately — it reads the
pointer once, smooths it through a single spring, and lets both the tilt and
the glare's position fall out of that one signal. Here's how a pointermove
handler becomes a card that feels like it's made of glass.
The three layers
Everything lives inside one [perspective:1000px] wrapper. The card itself
carries rotateX/rotateY in a preserve-3d box; a content layer floats
toward the viewer on translateZ(depth) so children read as physically
closer; and a glare layer sits on top, its radial gradient centered wherever
the pointer is.
- Card (z 0)
- rotateX/Y from the spring-smoothed pointer
- Content (translateZ 40px)
- children float toward the viewer for parallax
- Glare (topmost)
- radial highlight centered on the pointer
<div className="[perspective:1000px]">
<motion.div style={{ rotateX, rotateY }} className="[transform-style:preserve-3d]">
<div style={{ transform: `translateZ(${depth}px)`, transformStyle: "preserve-3d" }}>
{children}
</div>
<motion.div style={{ "--gx": glareX, "--gy": glareY }} />
</motion.div>
</div>One pointer, one spring, two mappings
onPointerMove normalizes the cursor to [-0.5, 0.5] on each axis relative
to the card's bounding rect, and writes that straight into two motion
values — no rotation math happens here yet:
const px = useMotionValue(0);
const py = useMotionValue(0);
const sx = useSpring(px, SPRING);
const sy = useSpring(py, SPRING);
px.set((e.clientX - rect.left) / rect.width - 0.5);
py.set((e.clientY - rect.top) / rect.height - 0.5);sx/sy are the spring-smoothed versions of those raw values, and
SPRING — { stiffness: 170, damping: 12, mass: 0.1 } — is deliberately
light and slightly underdamped, so the card catches up to the pointer fast
and settles with a touch of overshoot instead of snapping. Four
useTransform calls fan that pair of springs out into rotation and glare
position:
const rotateX = useTransform(sy, [-0.5, 0.5], [maxTilt, -maxTilt]);
const rotateY = useTransform(sx, [-0.5, 0.5], [-maxTilt, maxTilt]);
const glareX = useTransform(sx, [-0.5, 0.5], ["0%", "100%"]);
const glareY = useTransform(sy, [-0.5, 0.5], ["0%", "100%"]);Because the tilt and the glare read the same two springs, they can never drift out of sync — move the pointer and the highlight always sits on the side of the card that's tilted toward it.
useTransform(sx, [-0.5, 0.5], ["0%", "100%"]) → --gx / --gy
- Card tilt
- rotateX/rotateY, spring mass 0.1 · stiffness 170 · damping 12
- Glare
- same sx/sy, mapped to a radial-gradient position
Leaving the card resets px/py to 0, and the same spring eases the tilt
and glare back to flat rather than snapping — onPointerLeave never touches
rotateX/rotateY directly, it just gives the spring a new target.
Why transform, not layout
rotateX, rotateY, scale (on whileHover), and the glare's CSS-variable
position are the only things that change per frame. All of it composes onto
the transform/paint layers the GPU already owns — the browser never
re-measures the card while the spring is settling, no matter how fast the
pointer moves across it.
Accessibility
Under prefers-reduced-motion, TiltCard skips the motion tree entirely and
returns a plain <div> — no spring, no pointer listener, no preserve-3d
wrapper:
if (reduceMotion) {
return (
<div ref={ref} data-slot="tilt-card" className={ROOT_BASE} style={style}>
{children}
</div>
);
}The result
Move your pointer across each card — the rotation, the depth, and the glare all come from the same spring.
Designed in 3D
One spring drives rotation, depth, and glare together.
Deeper parallax
Crank maxTilt and depth for a more dramatic effect.
One pointer position, one spring, four useTransform calls — that's the
whole card.