Tilt Card
A card that tilts toward the pointer in 3D and catches a top-lit glare that tracks the cursor.
Built to feel real
Move your cursor over the card. It tilts toward you in 3D, catches the light from above, and settles with a spring when you leave.
Two spring pairs drive the tilt and the glare, so the card has momentum and settles instead of snapping to the raw pointer position. The glare only ever moves across the top of the card, keeping the light source physically honest. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/tilt-card.tsx.
'use client';import * as React from 'react';import { motion, useMotionTemplate, useReducedMotion, useSpring, useTransform,} from 'motion/react';import { cn } from '@/lib/cn';const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--tilt-accent)]';export type TiltCardProps = { maxTilt?: number; glare?: boolean; accent?: string; children?: React.ReactNode; className?: string;};export function TiltCard({ maxTilt = 10, glare = true, accent = '#f0883e', children, className,}: TiltCardProps) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLDivElement>(null); const spring = { stiffness: 200, damping: 22, mass: 0.6 } as const; const rotateX = useSpring(0, spring); const rotateY = useSpring(0, spring); const lift = useSpring(0, spring); const gx = useSpring(50, spring); const gy = useSpring(0, spring); const glareOpacity = useSpring(0, { stiffness: 160, damping: 26 }); const y = useTransform(lift, (l) => l * -6); const glareBg = useMotionTemplate`radial-gradient(60% 60% at ${gx}% ${gy}%, rgba(255,255,255,0.22), transparent 60%)`; const onMove = (e: React.PointerEvent<HTMLDivElement>) => { if (reduce) return; const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const px = (e.clientX - r.left) / r.width - 0.5; const py = (e.clientY - r.top) / r.height - 0.5; rotateY.set(px * maxTilt * 2); rotateX.set(-py * maxTilt * 2); gx.set((px + 0.5) * 100); gy.set((py + 0.5) * 100); }; const onEnter = () => { if (reduce) return; lift.set(1); glareOpacity.set(glare ? 1 : 0); }; const onLeave = () => { rotateX.set(0); rotateY.set(0); lift.set(0); glareOpacity.set(0); }; return ( <div style={{ perspective: 900, '--tilt-accent': accent } as React.CSSProperties} className={cn('relative', className)} > <motion.div ref={ref} tabIndex={0} onPointerMove={onMove} onPointerEnter={onEnter} onPointerLeave={onLeave} whileTap={reduce ? undefined : { scale: 0.985 }} style={{ rotateX, rotateY, y, borderRadius: 14, transformStyle: 'preserve-3d', }} className={cn( 'bloom-edge group relative overflow-hidden', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_8px_22px_-12px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_12px_28px_-14px_rgba(0,0,0,0.6)]', FOCUS, )} > {glare && ( <motion.div aria-hidden className="pointer-events-none absolute inset-0 z-10" style={{ background: glareBg, opacity: glareOpacity }} /> )} <div className="relative z-0">{children ?? <TiltCardSample />}</div> </motion.div> </div> );}function TiltCardSample() { return ( <div className="w-[300px] max-w-full p-6"> <span className="bloom-edge grid size-10 place-items-center rounded-[10px] bg-black/[0.03] text-neutral-700 dark:bg-black/20 dark:text-neutral-200 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <path d="M13 2 3 14h7l-1 8 10-12h-7z" /> </svg> </span> <h3 className="mt-4 text-[16px] font-semibold tracking-[-0.01em]"> Built to feel real </h3> <p className="mt-1.5 text-[13px] leading-relaxed text-neutral-500 dark:text-[#929292]"> Move your cursor over the card. It tilts toward you in 3D, catches the light from above, and settles with a spring when you leave. </p> </div> );}Usage
import { TiltCard } from '@/components/ui/tilt-card';
export default function Example() {
return <TiltCard />;
}Examples
Pass any children to tilt custom content, like a media card.
Aurora Pack
A media card that tilts toward the cursor, great for product and gallery grids.
A grid of tilt cards with the glare turned off, for a calmer effect.
Fast
Springs settle in under 200ms.
Deep
Real perspective, lit from above.
Calm
Tilt stops under reduced motion.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
maxTilt | number | 10 | Maximum tilt in degrees at the card's edges. |
glare | boolean | true | Show the moving top-lit glare. |
accent | string | "#f0883e" | Accent for the keyboard focus outline only. |
children | React.ReactNode | - | Custom content; falls back to a sample card. |
className | string | - | Forwarded to the outer wrapper. |