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
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { useMediaQuery } from "@/hooks/use-media-query"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

const FINE_POINTER = "(pointer: fine)"

export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Maximum rotation per axis, in degrees. */
  maxTilt?: number
  /** Scale applied while the pointer is over the card. */
  scale?: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tilt-card.json

Prompt

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's className, ref and spread props all target the card itself.
  • Cursor-tracked glare — a color-mix radial gradient anchored at the pointer, aria-hidden and pointer-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 conflicting transition-property classes.
  • Depth without blur — transform-3d lets children use translate-z-*, while overflow-hidden and will-change are deliberately avoided: the first flattens 3D, the second is what makes text look fuzzy.

On This Page