Tilt Card
A card that leans toward the pointer and catches the light with a soft moving glare.
- Card
- Interaction
- 3D
Uses clsx, tailwind-merge
Keyframe
Move your pointer across me
The card leans toward the cursor and catches the light.
import { TiltCard } from "@/components/ui/tilt-card";
export function TiltCardDemo() {
return (
<TiltCard className="w-72 border border-zinc-200 bg-linear-to-br from-white to-zinc-100 p-6 shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:from-zinc-900 dark:to-zinc-950 dark:shadow-black/40">
<p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
Keyframe
</p>
<h3 className="mt-20 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Move your pointer across me
</h3>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
The card leans toward the cursor and catches the light.
</p>
</TiltCard>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/tilt-card.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/tilt-cardInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/tilt-card.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> { /** Maximum rotation in degrees when the pointer is at an edge. */ maxTilt?: number; /** Scale applied while the pointer is over the card. */ scale?: number; /** CSS perspective distance in pixels. */ perspective?: number; /** Show a light sheen that follows the pointer. */ glare?: boolean; } export function TiltCard({ maxTilt = 12, scale = 1.03, perspective = 900, glare = true, className, style, children, onPointerEnter, onPointerMove, onPointerLeave, ...props }: TiltCardProps) { const ref = React.useRef<HTMLDivElement>(null); // Pointer position is written straight to CSS variables, so moving the // pointer never re-renders the card. const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => { onPointerEnter?.(event); ref.current?.style.setProperty("--tilt-scale", String(scale)); ref.current?.style.setProperty("--glare-opacity", "1"); }; const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { onPointerMove?.(event); const el = ref.current; if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { return; } const rect = el.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = (event.clientY - rect.top) / rect.height; el.style.setProperty("--tilt-x", `${(0.5 - y) * maxTilt * 2}deg`); el.style.setProperty("--tilt-y", `${(x - 0.5) * maxTilt * 2}deg`); el.style.setProperty("--glare-x", `${x * 100}%`); el.style.setProperty("--glare-y", `${y * 100}%`); }; const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => { onPointerLeave?.(event); const el = ref.current; if (!el) return; el.style.setProperty("--tilt-x", "0deg"); el.style.setProperty("--tilt-y", "0deg"); el.style.setProperty("--tilt-scale", "1"); el.style.setProperty("--glare-opacity", "0"); }; return ( <div ref={ref} className={cn( "relative rounded-2xl transition-transform duration-200 ease-out will-change-transform", className, )} style={{ transform: `perspective(${perspective}px) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg)) scale(var(--tilt-scale, 1))`, ...style, }} onPointerEnter={handlePointerEnter} onPointerMove={handlePointerMove} onPointerLeave={handlePointerLeave} {...props} > {children} {glare ? ( <div aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light transition-opacity duration-200" style={{ opacity: "var(--glare-opacity, 0)", background: "radial-gradient(circle at var(--glare-x, 50%) var(--glare-y, 50%), rgba(255, 255, 255, 0.55), transparent 55%)", }} /> ) : null} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Tilt Card. Each one installs whole, with the component included.
Pricing Cards
Three plan cards, with the featured plan leaning harder.
Hobby
$0 / month
For side projects and experiments.
- 3 projects
- Community support
- Basic analytics
Pro
$24 / month
For teams shipping every week.
- Unlimited projects
- Priority support
- Advanced analytics
Team
$79 / month
For organizations that need control.
- SSO and audit logs
- Dedicated support
- Custom roles
import { TiltCard } from "@/components/ui/tilt-card";
const plans = [
{
name: "Hobby",
price: "$0",
blurb: "For side projects and experiments.",
features: ["3 projects", "Community support", "Basic analytics"],
featured: false,
},
{
name: "Pro",
price: "$24",
blurb: "For teams shipping every week.",
features: ["Unlimited projects", "Priority support", "Advanced analytics"],
featured: true,
},
{
name: "Team",
price: "$79",
blurb: "For organizations that need control.",
features: ["SSO and audit logs", "Dedicated support", "Custom roles"],
featured: false,
},
];
export function TiltCardPricingDemo() {
return (
<div className="grid w-full max-w-4xl gap-5 md:grid-cols-3">
{plans.map((plan) => (
<TiltCard
key={plan.name}
maxTilt={plan.featured ? 14 : 8}
scale={plan.featured ? 1.05 : 1.02}
className={
plan.featured
? "border border-zinc-900 bg-zinc-900 p-6 text-zinc-50 shadow-2xl dark:border-zinc-100 dark:bg-zinc-50 dark:text-zinc-900"
: "border border-zinc-200 bg-white p-6 text-zinc-900 shadow-lg shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-black/30"
}
>
<p className="font-mono text-xs uppercase tracking-widest opacity-60">
{plan.name}
</p>
<p className="mt-4 text-4xl font-semibold tracking-tight">
{plan.price}
<span className="text-sm font-normal opacity-60"> / month</span>
</p>
<p className="mt-2 text-sm opacity-70">{plan.blurb}</p>
<ul className="mt-6 space-y-2 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span aria-hidden className="size-1.5 rounded-full bg-current opacity-50" />
{feature}
</li>
))}
</ul>
</TiltCard>
))}
</div>
);
}
Feature Grid
A grid of subtle tiles with a gentler tilt and no glare.
Built for interaction
One card, four small details
01
Pointer aware
Rotation follows the cursor across the whole card.
02
No re-renders
Position is written to CSS variables, not React state.
03
Reduced motion
Tilt is skipped for people who ask for less movement.
04
Fully themable
Plain Tailwind classes. Bring your own colors.
import { TiltCard } from "@/components/ui/tilt-card";
const features = [
{ index: "01", title: "Pointer aware", body: "Rotation follows the cursor across the whole card." },
{ index: "02", title: "No re-renders", body: "Position is written to CSS variables, not React state." },
{ index: "03", title: "Reduced motion", body: "Tilt is skipped for people who ask for less movement." },
{ index: "04", title: "Fully themable", body: "Plain Tailwind classes. Bring your own colors." },
];
export function TiltCardFeatureGridDemo() {
return (
<div className="w-full max-w-3xl">
<p className="font-mono text-xs uppercase tracking-widest text-zinc-500">
Built for interaction
</p>
<h3 className="mt-3 max-w-md text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
One card, four small details
</h3>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
{features.map((feature) => (
<TiltCard
key={feature.index}
glare={false}
maxTilt={5}
scale={1.015}
className="border border-zinc-200 bg-zinc-50 p-5 dark:border-zinc-800 dark:bg-zinc-900/60"
>
<p className="font-mono text-xs text-zinc-500">{feature.index}</p>
<h4 className="mt-6 font-medium text-zinc-900 dark:text-zinc-50">
{feature.title}
</h4>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{feature.body}
</p>
</TiltCard>
))}
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | Content rendered inside the card. |
className | string | None | Extra classes for the card container. |
maxTilt | number | 12 | Maximum rotation in degrees when the pointer is at an edge. |
scale | number | 1.03 | Scale applied while the pointer is over the card. |
perspective | number | 900 | CSS perspective distance in pixels. Lower values feel more dramatic. |
glare | boolean | true | Show a light sheen that follows the pointer. |
childrenType
React.ReactNodeContent rendered inside the card.
classNameType
stringExtra classes for the card container.
maxTiltType
numberDefault12Maximum rotation in degrees when the pointer is at an edge.
scaleType
numberDefault1.03Scale applied while the pointer is over the card.
perspectiveType
numberDefault900CSS perspective distance in pixels. Lower values feel more dramatic.
glareType
booleanDefaulttrueShow a light sheen that follows the pointer.