The 3D card tilt everyone rebuilds, done properly once
You've seen the effect a hundred times: hover a pricing card and it leans toward your cursor, a soft glare sweeps across it, and the title seems to float a few pixels above the surface. It's the "holographic card" look, and it's quietly become table stakes for landing pages.
It's also one of those effects everyone rebuilds badly. The naive version is 40 lines of pointer math in a useEffect — and then the edge cases start: it re-renders the whole card on every mousemove, it dies silently under React StrictMode, it does nothing on phones, and it keeps wobbling for people who asked their OS for less motion.
So I built tilt-card-react: a zero-dependency React library that does the whole thing — pointer tilt, glare, parallax depth layers, gyroscope on mobile — in about 1.6 kB gzipped.
What it does
The easy path is one component:
import { TiltCard, parallax } from 'tilt-card-react';
<TiltCard glare maxTilt={12}>
<h3 style={parallax(40)}>Floats above the card</h3>
<p>Leans toward the pointer. Follows the gyro on mobile.</p>
</TiltCard>TiltCard renders a plain div you can style however you like. glare adds the tracking sheen, and parallax(depth) lifts any child onto its own z-plane so the card reads as a physical object instead of a flat rectangle.
When you want your own markup, the headless hook underneath is exported too: useTilt() hands you spreadable props (ref, style, two pointer handlers) and an onTilt callback with the live rotation.
Decision one: CSS custom properties, not React state
The obvious implementation stores rotateX/rotateY in React state. That means a full component re-render for every pointer move — sixty-plus renders a second, per card, in exchange for animating two numbers.
tilt-card-react never touches state while tilting. The pointer handler writes CSS custom properties directly onto the element — --tilt-rx, --tilt-ry, --tilt-gx, --tilt-go and friends — and the card's static transform reads them with var():
transform: perspective(1000px) rotateX(var(--tilt-rx, 0deg)) rotateY(var(--tilt-ry, 0deg));React renders the card exactly once. Resetting on pointer-leave is just removing the properties, because every var() carries its resting value as a fallback. The bonus is composability: the properties inherit, so any child — including glare layers you build yourself — can join the animation with plain CSS. The glare layer in the library is literally a static radial-gradient positioned at var(--tilt-gx) var(--tilt-gy).
Decision two: the gyroscope shouldn't care how you hold your phone
Hover doesn't exist on touch screens, and most tilt implementations just give up there. Phones have a better input anyway: the gyroscope.
The naive mapping — device pitch straight to card tilt — breaks immediately, because nobody holds a phone at 0°. Flat on a desk is ~0°, in your hand it's more like 45°, lying in bed it's upside down. Absolute angles mean the card starts pinned at maximum tilt.
So the hook calibrates: the first orientation reading becomes neutral, and tilt is computed relative to it. However you're holding the device when the card appears is "flat", and rocking the phone ±32° sweeps the full range. One wrinkle remains — iOS 13+ gates orientation events behind a permission prompt that only opens from a user gesture — so the library ships a requestGyroPermission() helper you wire to a button, and everything else stays automatic.
There's one more trap worth naming: React 19's StrictMode simulates an unmount/remount in dev, and the popular pattern of starting listeners in a ref callback while cleaning up in an effect leaves you with a dead listener and no error. Every listener in tilt-card-react lives inside a single effect keyed on the observed node, so StrictMode tears it down and rebuilds it correctly. The test suite pins that behavior — along with everything else, at 100% branch coverage, including jsdom-simulated orientation events with the null readings desktop Firefox sends.
Try it
pnpm add tilt-card-reactHover the demo, or open it on your phone and tilt. If the OS asks for reduced motion, the cards politely hold still — that's the default, not an option you have to remember.
What's next
On the list: a TiltGroup that shares one orientation listener across many cards, an optional gyro smoothing filter for older devices with noisy sensors, and SSR-friendly presets for common looks (subtle, dramatic, holo). If you ship it somewhere fun — or find a device that defeats the calibration — the issue tracker is open.
End of essay



