Display
Tilt Card
A card container that leans toward the pointer in 3D, with an optional gloss that tracks the cursor.
Preview in your theme
Loading preview…
- Registry dependencies
- utils, use-media-query, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/tilt-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TiltCard" component (no runtime
dependencies beyond React and the shared cn() utility).
Contract
- Shared primitives (installed automatically via registryDependencies):
use-media-query (`useMediaQuery("(pointer: fine)")`) and
use-prefers-reduced-motion (`usePrefersReducedMotion()`) are the two
environment gates. If rebuilding without them: each is a useSyncExternalStore
over matchMedia(query) with a "change" listener and a server snapshot of
false.
- Export a forwardRef<HTMLDivElement, TiltCardProps> extending
React.HTMLAttributes<HTMLDivElement>; spread the remaining props onto the
root so it stays a drop-in card container.
- Props: maxTilt = 10 (degrees per axis), scale = 1.02 (applied while the
pointer is over it), glare = true, perspective = 900 (px),
disabled = false, plus className / style / children.
- children are arbitrary: the component owns the motion and the card
surface, never the content.
Behavior
- onPointerMove: measure the root with getBoundingClientRect, convert the
pointer into -1..1 offsets from the centre, then set
rotateX = offsetY * maxTilt and rotateY = -offsetX * maxTilt. The signs
matter: the edge under the pointer should lift toward the viewer, so the
card appears to face the cursor rather than be pushed away by it.
- The per-move pose is written onto the root as CSS custom properties
(--tilt-rx / --tilt-ry in deg, --tilt-gx / --tilt-gy in %) with
style.setProperty, and the transform / glare gradient read them through
var(). Only a boolean "tracking" flag lives in React state, so pointermove
re-renders nothing once the pointer is on the card.
- Guard against a zero-sized rect (a 0 width/height during layout thrash
would divide by zero) and bail out instead of writing NaN degrees.
- onPointerLeave: clear tracking (the transform is dropped, so rotation and
scale ease home from the last pose) and re-centre the glare origin.
- Transition discipline: while the pointer drives it the transition duration
is 0 so the card tracks 1:1; on leave a ~300ms eased transition carries it
home. Use one transition-[transform,box-shadow] declaration with a swapped
duration — never two competing transition-property classes.
- Environment gating via two unconditional hook calls, combined after the
fact: finePointer = useMediaQuery("(pointer: fine)"), reducedMotion =
usePrefersReducedMotion(), supported = finePointer && !reducedMotion. Tilt
runs only on a fine pointer with motion allowed; otherwise it degrades to a
plain static card (the hover shadow still works) and the glare layer is not
rendered at all. The hooks' store subscriptions are the component's only
listener bookkeeping.
- Both server snapshots are false, so SSR and the first client render agree
and no hydration mismatch is possible.
- disabled forces the flat card even on a desktop pointer.
Rendering & styling
- Single element: perspective lives inside the transform
(perspective(Npx) rotateX() rotateY() scale()), so there is no wrapper —
className, ref and spread props all land on the card itself.
- Surface: rounded-xl border bg-card text-card-foreground shadow-sm with
hover:shadow-lg. Semantic tokens only, no hardcoded colors.
- Glare: an aria-hidden, pointer-events-none span, absolute inset-0 with
rounded-[inherit] so it follows a caller-overridden radius, painted as a
radial-gradient at the pointer position with
color-mix(in oklab, var(--primary) 22%, transparent); it fades in and out
on enter/leave.
- Do NOT put overflow-hidden on the root: a non-visible overflow forces
transform-style back to flat and kills depth for children. The root keeps
transform-3d, so any child with translate-z-* floats above the surface
while tilting (and sits flat at rest, because there is no perspective
then).
- Skip will-change: permanently promoting the card to its own layer is what
makes text look fuzzy under a 3D transform.
- Merge the consumer className through cn().
Customization levers
- Tilt strength: maxTilt 6–8 for enterprise-calm, 15–20 for playful.
perspective is the paired knob — a lower perspective (600) makes the same
angle read much stronger.
- scale: 1 disables the hover lift entirely; 1.05+ makes a "pick me" card.
- glare={false} over busy imagery, where a moving gloss competes with the
content; or retint it by swapping --primary for --accent in the color-mix.
- Depth: add translate-z-* to any child (a caption block, a badge) to lift
it off the surface; keep image layers flat so they don't shear.
- Surface: override rounded / border / bg through className — the glare
inherits the radius, so nothing else needs updating.
- Whole-card links: wrap the content in your own <a>/<button>, or pass
onClick through the spread props; the component adds no click affordance
of its own.Concepts
- Pointer parallax — the rotation is a pure function of where the cursor sits inside the box, so the motion reads as depth rather than as an animation playing at you.
- Fine-pointer gating —
(pointer: fine)is the honest test for "can this visitor hover at all"; on touch the tilt is never wired up instead of being fired by stray taps. - Perspective inside the transform — putting
perspective()in the transform list keeps the component a single element, so the consumer'sclassName,refand spread props all target the card itself. - Cursor-tracked glare — a
color-mixradial gradient anchored at the pointer,aria-hiddenandpointer-events-none, on a layer that inherits the card's radius instead of being clipped. - Asymmetric transition — none while tracking (the card has to feel attached to the cursor), eased on the way back; one
transition-[transform,box-shadow]with a swapped duration avoids two conflictingtransition-propertyclasses. - Depth without blur —
transform-3dlets children usetranslate-z-*, whileoverflow-hiddenandwill-changeare deliberately avoided: the first flattens 3D, the second is what makes text look fuzzy.
Mini Calendar
A compact month grid for picking one date — keyboard-navigable, timezone-safe, with today, disabled days and event dots. No date library.
Notification Badge
A count bubble that wraps any element — rolling digits, a bump on change, max overflow, dot mode with a ping, and four corner placements.