GodUIGodUI
129Follow on X

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.

01/03The three layers
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
tsx
<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>
01

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.

02/03One pointer, one spring, two mappings

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
tsx
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);
02

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:

tsx
 
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.

03/03Result

Designed in 3D

One spring drives rotation, depth, and glare together.

Deeper parallax

Crank maxTilt and depth for a more dramatic effect.

03

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:

tsx
 
if (reduceMotion) {
  return (
    <div ref={ref} data-slot="tilt-card" className={ROOT_BASE} style={style}>
      {children}
    </div>
  );
}

Motion Score

Tilt CardSS: Compositor-only
StranslatePosition / lift via translate
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →