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.
↓Scroll to step through it
The three layers
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.
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.
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:
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.
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.
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.
The result
Move your pointer across each card — the rotation, the depth, and the glare all come from the same spring.
One pointer position, one spring, four useTransform calls — that's the
whole card.
- 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>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.
- 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>The three layers
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.
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.
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
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);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:
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.
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.
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.
Designed in 3D
One spring drives rotation, depth, and glare together.
Deeper parallax
Crank maxTilt and depth for a more dramatic effect.
The result
Move your pointer across each card — the rotation, the depth, and the glare all come from the same spring.
One pointer position, one spring, four useTransform calls — that's the
whole card.
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>
);
}Motion Score
translatePosition / lift via translaterotateRotationopacityFade / cross-fade