Cards & Layoutv0.1.0
Tilt Card
3D perspective tilt with cursor-tracked glare.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/tilt-card.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/tilt-card/tilt-card.tsx"use client"; import type { ComponentProps, PointerEvent } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; export interface TiltCardProps extends Omit<ComponentProps<"div">, "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Tilt in degrees at the card's edges. @default 10 */ maxTilt?: number; /** Perspective distance in px; smaller is more dramatic. @default 900 */ perspective?: number; /** Scale while the pointer is over the card. @default 1.02 */ hoverScale?: number; /** Show a soft highlight that follows the cursor. @default true */ glare?: boolean; /** Peak opacity of the glare. @default 0.3 */ glareOpacity?: number; } const FOLLOW = { stiffness: 260, damping: 24, mass: 0.6 }; export function TiltCard({ maxTilt = 10, perspective = 900, hoverScale = 1.02, glare = true, glareOpacity = 0.3, className, style, children, onPointerMove, onPointerLeave, ...props }: TiltCardProps) { const reduceMotion = useReducedMotion(); // Pointer position over the card, from -0.5 to 0.5 on each axis. const px = useMotionValue(0); const py = useMotionValue(0); const hover = useMotionValue(0); const x = useSpring(px, FOLLOW); const y = useSpring(py, FOLLOW); const h = useSpring(hover, FOLLOW); const rotateY = useTransform(x, (v) => v * maxTilt * 2); const rotateX = useTransform(y, (v) => -v * maxTilt * 2); const scale = useTransform(h, (v) => 1 + (hoverScale - 1) * v); // The glare layer is twice the card's size; shifting it by half the pointer offset puts its center under the cursor. const glareX = useTransform(x, (v) => `${v * 50}%`); const glareY = useTransform(y, (v) => `${v * 50}%`); const glareAlpha = useTransform(h, (v) => v * glareOpacity); const handleMove = (event: PointerEvent<HTMLDivElement>) => { onPointerMove?.(event); if (reduceMotion || event.pointerType !== "mouse") return; const card = event.currentTarget; // The bounding box's center doesn't move while the card tilts, and offset sizes ignore transforms, // so the mapping stays stable mid-tilt. const rect = card.getBoundingClientRect(); px.set(Math.max(-0.5, Math.min(0.5, (event.clientX - (rect.left + rect.width / 2)) / card.offsetWidth))); py.set(Math.max(-0.5, Math.min(0.5, (event.clientY - (rect.top + rect.height / 2)) / card.offsetHeight))); hover.set(1); }; const handleLeave = (event: PointerEvent<HTMLDivElement>) => { onPointerLeave?.(event); px.set(0); py.set(0); hover.set(0); }; return ( <motion.div className={cn("relative [transform-style:preserve-3d]", className)} style={{ ...style, transformPerspective: perspective, rotateX, rotateY, scale }} onPointerMove={handleMove} onPointerLeave={handleLeave} {...props} > {children} {/* Always rendered (server and client match); under reduced motion it simply stays at opacity 0. */} {glare ? ( <span aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"> <motion.span className="absolute inset-[-50%] mix-blend-soft-light" style={{ x: glareX, y: glareY, opacity: glareAlpha, background: "radial-gradient(circle at center, rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.25) 22%, transparent 48%)", }} /> </span> ) : null} </motion.div> ); }
Usage
import { TiltCard } from "@/components/ui/tilt-card/tilt-card";
export function CreditCard() {
return (
<TiltCard className="aspect-[1.586] w-80 rounded-2xl bg-zinc-900 p-6 text-white">
{/* translateZ lifts a child toward the viewer as the card tilts */}
<div className="[transform:translateZ(36px)]">…</div>
</TiltCard>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
maxTilt | number | 10 | Tilt in degrees at the card's edges. |
perspective | number | 900 | Perspective distance in px; smaller is more dramatic. |
hoverScale | number | 1.02 | Scale while the pointer is over the card. |
glare | boolean | true | Show a soft highlight that follows the cursor. |
glareOpacity | number | 0.3 | Peak opacity of the glare. |
Also accepts every native <div> attribute, including ref and className.
- Uses transformPerspective on the card itself, so no wrapper element is needed. Children with translateZ get real depth thanks to preserve-3d.
- Pointer math uses the bounding box's center (which doesn't move while tilting) and offset sizes (which ignore transforms), so the tilt never wobbles.
- The glare is a radial highlight on a layer twice the card's size, moved with transforms and blended with soft-light. It shows best on dark or saturated cards.
Accessibility
- Purely decorative: the card's content and semantics are untouched, and the glare layer is aria-hidden.
- Tilt follows a mouse only; touch and pen input never tilt, so scrolling a page of cards stays smooth.
- With reduced motion enabled, the card doesn't tilt, scale or show glare.