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…
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
- 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.
- 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: reset rotation, scale and glare origin in one update.
- 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 useSyncExternalStore over two media queries:
"(pointer: fine)" and "(prefers-reduced-motion: reduce)". 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. Both listeners are removed in the subscribe cleanup —
that store subscription is the component's only listener bookkeeping.
- The server snapshot is 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.