A card that tilts in 3D toward the pointer with spring smoothing and a moving glare. TiltCardLayer children float at their own depth for parallax. Keyboard focus shows a gentle tilt.
import { Nfc } from "lucide-react"
import { TiltCard, TiltCardLayer } from "@/components/ballmac/tilt-card"
function Chip() {
return (
<svg viewBox="0 0 46 36" className="h-9 w-[46px] drop-shadow-[0_1px_1px_rgb(0_0_0/0.25)]" aria-hidden="true">
<defs>
<linearGradient id="tilt-demo-chip" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="color-mix(in oklch, var(--chart-3) 70%, white)" />
<stop offset="50%" stopColor="var(--chart-3)" />
<stop offset="100%" stopColor="color-mix(in oklch, var(--chart-3) 70%, black)" />
</linearGradient>
</defs>
<rect x="0.5" y="0.5" width="45" height="35" rx="7" fill="url(#tilt-demo-chip)" />
<g fill="none" stroke="rgb(0 0 0 / 0.28)" strokeWidth="1">
<path d="M0.5 12.5h13a4 4 0 0 1 4 4v3a4 4 0 0 1-4 4h-13" />
<path d="M45.5 12.5h-13a4 4 0 0 0-4 4v3a4 4 0 0 0 4 4h13" />
<path d="M17.5 18h11M23 0.5v12M23 23.5v12" />
</g>
</svg>
)
}
export default function TiltCardDemo() {
return (
<div className="flex w-full justify-center px-2 py-10">
<TiltCard
tabIndex={0}
role="group"
aria-label="Acme membership card, member Jordan Avery, since 2024"
maxTilt={14}
className="aspect-[1.586] w-full max-w-[360px] rounded-[22px] border-0 bg-primary text-primary-foreground shadow-[0_30px_60px_-24px_rgb(0_0_0/0.55),0_0_0_1px_rgb(255_255_255/0.06)_inset]"
>
{/* Surface: aurora and fine texture, clipped to the card. */}
<div aria-hidden="true" className="absolute inset-0 overflow-hidden rounded-[inherit]">
<div className="absolute -top-1/2 -start-1/4 size-[120%] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--chart-1)_55%,transparent),transparent)] opacity-70 blur-2xl" />
<div className="absolute -end-1/3 -bottom-2/3 size-[110%] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--chart-4)_55%,transparent),transparent)] opacity-60 blur-2xl" />
<div className="absolute inset-0 bg-[repeating-linear-gradient(115deg,rgb(255_255_255/0.035)_0_1px,transparent_1px_6px)]" />
</div>
<div className="relative flex h-full flex-col justify-between p-6 transform-3d">
<TiltCardLayer depth={40} className="flex items-start justify-between">
<span className="flex items-center gap-2">
<span className="size-6 rounded-[7px] bg-[conic-gradient(from_210deg,var(--chart-1),var(--chart-4),var(--chart-2),var(--chart-1))] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.25)]" />
<span className="text-[15px] font-semibold tracking-tight">Ballmac</span>
</span>
<span className="rounded-full border border-current/20 px-2.5 py-0.5 text-[10px] font-semibold tracking-[0.2em]">
PRO
</span>
</TiltCardLayer>
<TiltCardLayer depth={28} className="flex items-center gap-4">
<Chip />
<Nfc className="size-6 opacity-70" aria-hidden="true" />
{/* Holographic foil: its gradient slides with the tilt via --tilt-x / --tilt-y. */}
<span
aria-hidden="true"
className="ms-auto h-8 w-12 rounded-md bg-[linear-gradient(115deg,var(--chart-1),var(--chart-2),var(--chart-3),var(--chart-5),var(--chart-4),var(--chart-1))] bg-[length:300%_300%] opacity-80 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.3)] [background-position:var(--tilt-x,50%)_var(--tilt-y,50%)]"
/>
</TiltCardLayer>
<TiltCardLayer depth={20} className="flex items-end justify-between gap-4">
<span>
<span className="block text-[9px] font-medium tracking-[0.2em] opacity-60">MEMBER</span>
<span className="block text-[15px] font-medium tracking-wide">Jordan Avery</span>
</span>
<span className="text-end">
<span className="block font-mono text-[13px] tracking-widest opacity-80">•••• 2048</span>
<span className="block text-[10px] opacity-60">Since 2024</span>
</span>
</TiltCardLayer>
</div>
</TiltCard>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/tilt-cardInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/tilt-card.tsx// Ballmac UI: Tilt Card. https://ui.ballmac.com/components/tilt-card "use client" import * as React from "react" import { motion, type MotionStyle, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react" import { cn } from "@/lib/utils" import { useReducedMotionSafe } from "@/lib/ballmac/motion" type TiltCardProps = Omit< React.ComponentProps<"div">, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "style" > & { /** Largest rotation on each axis, in degrees. */ maxTilt?: number /** Distance of the viewer in pixels. Smaller values exaggerate depth. */ perspective?: number /** Scale while the pointer is over the card. */ hoverScale?: number /** Draw a soft light highlight that follows the pointer. */ glare?: boolean /** Peak opacity of the glare, 0 to 1. */ glareOpacity?: number /** Inline styles for the card. */ style?: React.CSSProperties } const SPRING = { stiffness: 220, damping: 22, mass: 0.6 } function isFocusVisible(element: Element) { try { return element.matches(":focus-visible") } catch { return false } } function TiltCard({ maxTilt = 10, perspective = 900, hoverScale = 1.02, glare = true, glareOpacity = 0.28, className, style, children, onPointerMove, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: TiltCardProps) { const reduceMotion = useReducedMotionSafe() // Pointer position over the card, 0–1 on each axis; 0.5 is the center. const px = useMotionValue(0.5) const py = useMotionValue(0.5) const active = useMotionValue(0) const sx = useSpring(px, SPRING) const sy = useSpring(py, SPRING) const sActive = useSpring(active, SPRING) const rotateX = useTransform(sy, [0, 1], [maxTilt, -maxTilt]) const rotateY = useTransform(sx, [0, 1], [-maxTilt, maxTilt]) const scale = useTransform(sActive, [0, 1], [1, hoverScale]) const glareX = useTransform(sx, (v) => `${v * 100}%`) const glareY = useTransform(sy, (v) => `${v * 100}%`) const glareAlpha = useTransform(sActive, (v) => v * glareOpacity) const glareBackground = useMotionTemplate`radial-gradient(farthest-corner circle at ${glareX} ${glareY}, rgb(255 255 255 / ${glareAlpha}), transparent 62%)` const moveTo = (x: number, y: number, on: number) => { px.set(x) py.set(y) active.set(on) } const reset = () => moveTo(0.5, 0.5, 0) return ( <motion.div data-slot="tilt-card" className={cn( "group/tilt relative rounded-xl border bg-card text-card-foreground outline-none transform-3d focus-visible:ring-[3px] focus-visible:ring-ring/50", className )} style={ reduceMotion ? (style as MotionStyle) : ({ ...style, rotateX, rotateY, scale, transformPerspective: perspective, // Exposed for custom effects (holographic foil, parallax gradients): 0% to 100%. "--tilt-x": glareX, "--tilt-y": glareY, } as MotionStyle) } onPointerEnter={(event) => { if (!reduceMotion && event.pointerType !== "touch") active.set(1) onPointerEnter?.(event) }} onPointerMove={(event) => { if (!reduceMotion && event.pointerType !== "touch") { const rect = event.currentTarget.getBoundingClientRect() moveTo((event.clientX - rect.left) / rect.width, (event.clientY - rect.top) / rect.height, 1) } onPointerMove?.(event) }} onPointerLeave={(event) => { reset() onPointerLeave?.(event) }} onFocus={(event) => { // Keyboard focus gets a gentle, fixed tilt toward the top-right corner. if (!reduceMotion && isFocusVisible(event.target)) moveTo(0.68, 0.36, 0.7) onFocus?.(event) }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) reset() onBlur?.(event) }} {...props} > {children} {glare && !reduceMotion && ( <motion.span aria-hidden="true" data-slot="tilt-card-glare" className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] mix-blend-soft-light" style={{ background: glareBackground }} /> )} {glare && !reduceMotion && ( <motion.span aria-hidden="true" data-slot="tilt-card-sheen" className="pointer-events-none absolute inset-0 z-10 rounded-[inherit]" style={{ background: glareBackground, opacity: 0.6 }} /> )} </motion.div> ) } type TiltCardLayerProps = React.ComponentProps<"div"> & { /** How far the layer floats above the card, in pixels. Larger values move more with the tilt. */ depth?: number } function TiltCardLayer({ depth = 24, className, style, ...props }: TiltCardLayerProps) { return ( <div data-slot="tilt-card-layer" className={cn("transform-3d", className)} style={{ transform: `translateZ(${depth}px)`, ...style }} {...props} /> ) } export { TiltCard, TiltCardLayer, type TiltCardProps, type TiltCardLayerProps }Update the import paths to match your project setup.
Usage
import { TiltCard, TiltCardLayer } from "@/components/ballmac/tilt-card"The full example is in the Code tab above.
Examples
Linked product card
import { ArrowUpRight, Command } from "lucide-react"
import { TiltCard, TiltCardLayer } from "@/components/ballmac/tilt-card"
export default function TiltCardProduct() {
return (
<div className="flex w-full justify-center px-2 py-8">
<TiltCard maxTilt={8} className="w-full max-w-[300px] p-0">
<a
href="#"
className="block rounded-[inherit] p-5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<div className="relative flex h-40 items-center justify-center rounded-lg border bg-muted/50 transform-3d">
<div
aria-hidden="true"
className="absolute inset-0 rounded-[inherit] bg-[radial-gradient(circle_at_50%_40%,color-mix(in_oklch,var(--chart-1)_28%,transparent),transparent_65%)]"
/>
<TiltCardLayer depth={50}>
<span className="flex size-16 items-center justify-center rounded-2xl bg-[linear-gradient(145deg,var(--chart-1),var(--chart-4))] text-white shadow-[0_18px_30px_-12px_color-mix(in_oklch,var(--chart-4)_70%,transparent)]">
<Command className="size-7" aria-hidden="true" />
</span>
</TiltCardLayer>
</div>
<TiltCardLayer depth={16} className="mt-4 flex items-start justify-between gap-3">
<span>
<span className="block text-sm font-semibold">Command Palette</span>
<span className="mt-1 block text-sm text-muted-foreground">Every action, two keystrokes away.</span>
</span>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground rtl:-scale-x-100" aria-hidden="true" />
</TiltCardLayer>
</a>
</TiltCard>
</div>
)
}API reference
<TiltCard>
| Prop | Type | Default |
|---|---|---|
maxTiltLargest rotation on each axis, in degrees. | number | 10 |
perspectiveDistance of the viewer in pixels. Smaller values exaggerate depth. | number | 900 |
hoverScaleScale while the pointer is over the card. | number | 1.02 |
glareDraw a soft light highlight that follows the pointer. | boolean | true |
glareOpacityPeak opacity of the glare, 0 to 1. | number | 0.28 |
styleInline styles for the card. | React.CSSProperties | — |
<TiltCardLayer>
| Prop | Type | Default |
|---|---|---|
depthHow far the layer floats above the card, in pixels. Larger values move more with the tilt. | number | 24 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focusing the card (tabIndex) or a link inside it shows a gentle fixed tilt |
Use with AI
<TiltCard> is a card surface (rounded-xl, border, bg-card) that rotates toward the pointer; wrap parts in <TiltCardLayer depth={24}> to lift them off the surface. The card sets --tilt-x and --tilt-y (0%–100%) for custom foil or gradient effects. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tilt Card (@ballmac/tilt-card) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A single hero object: a membership or gift card, a product box, an app icon
- Pricing or product cards on a landing page that should feel physical
- Collectible or badge-style achievements
Not for
- Dense grids of many cards (the motion competes; use spotlight-card)
- Cards holding forms or text people need to read while moving the pointer
Registry JSON: https://ui.ballmac.com/r/tilt-card.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Spotlight Card
A card whose surface and border pick up a soft glow that follows the pointer, driven by CSS variables with no re-render per move. Keyboard focus shows a steady glow.
Border Beam
A short beam of light that travels around the inside edge of its parent's border at a constant speed. Decorative, token-colored, hidden under reduced motion.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.