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 derives both the tilt and the glare's position from that one signal.
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
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. The highlight always sits on the side of the card that's tilted toward the pointer.
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 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, so 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.
The card is one pointer position, one spring, and four useTransform calls.
- 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 derives both the tilt and the glare's position from that one signal.
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
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. The highlight always sits on the side of the card that's tilted toward the pointer.
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 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, so 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.
The card is one pointer position, one spring, and four useTransform calls.
Accessibility
Under prefers-reduced-motion, TiltCard skips the motion tree entirely and
returns a plain <div> with no spring, pointer listener, or 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