GodUIGodUI
81

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.

Anatomyone preserve-3d box, 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>

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:

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

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:

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 — move the pointer and the highlight always sits on the side of the card that's tilted toward it.

The tiltrotateX/Y and glare, one signal

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:

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

Resultthe real component — move your pointer across each card

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.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub