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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const FINE_POINTER = "(pointer: fine)"
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"

function subscribeTiltEnvironment(callback: () => void) {
  const pointer = window.matchMedia(FINE_POINTER)
  const motion = window.matchMedia(REDUCED_MOTION)
  pointer.addEventListener("change", callback)
  motion.addEventListener("change", callback)
  return () => {

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
- 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'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 blurtransform-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