3D tilt cards
Cards that lean toward the pointer like glass, with a light that follows and contents floating at their own depth. Focus lifts them too, on touch screens and with reduced motion they lie flat and calm.
Cards that lean toward you
A tilt card leans like glass toward the pointer while a soft light follows, and its contents float at their own depth. Keyboard focus lifts it the same way, and on touch screens and with reduced motion the cards lie flat and calm, so every visitor reads the same content.
Feature cards in depth float icon, title and text on three depths and make the whole card a link. Products rising out lift the product picture out of the card with the badge highest, while name, price and the add button stay on the card. Portraits with foil give team cards an accent sheen that turns with the light. Plans in depth set three plans with the recommended one standing forward. Event ticket leans one ticket with a perforated stub and a code beside the booking text.
The tilt strength is a prop, a few degrees feel premium. Tilt cards suit grids of three or four equal items. Put the essential text flat on the card, the floating layers are the accent.
Variants
Details
What Tilt cards brings along, read from the registry and the source.
- Category
- 3D surfaces
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/tilt-card.tsx
- npm packages
- None
- Languages
- English and German, 5 texts of its own
- Studio
- Adjust in code
- In collections
- 3D and motion
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TiltCardsProps interface from components/ui/tilt-card.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalTiltCardsVariantfeaturesproductprofilepricingticket
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsrequiredTiltItem[]mediaoptionalReact.ReactNode[]product: product pictures; profile: portraits; ticket: the code or artwork on the ticket
actionoptional{ label: string; href?: string }pricing, ticket: the button text
strengthoptionalnumberDegrees per full lean. Default 10.
Built-in texts
Tilt cards writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 5 texts
| Key | English | German |
|---|---|---|
| more | English Learn more | German Mehr erfahren |
| add | English Add to cart | German In den Warenkorb |
| per | English per month | German pro Monat |
| admit | English Admit one | German Einlass für eine Person |
| seat | English Seat | German Platz |
Accessibility
Read from the source of the component.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 338 lines
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Glare, ease3d, usePointerTilt } from '@/components/ui/depth';
/*
* Cards that lean toward the pointer like a sheet of glass, with a light that follows and contents
* floating at their own depth above the card. Each card turns on its own, a few degrees only, and
* settles back when the pointer leaves. Keyboard focus lifts the card and leans it slightly.
*
* Without JavaScript, on touch screens and with reduced motion the cards lie flat and calm: the
* same layout, the same contents, only the lean is missing.
*
* features icon, title and text on three depths, the whole card is a link
* product a product rises out of its card, name, price and an add button
* profile portrait cards with a foil sheen in the accent that moves with the light
* pricing three plans, the recommended one stands forward
* ticket one event ticket with a perforated stub and a code, the text beside it
*
* <TiltCard href="/features/sync"><TiltLayer depth={40}>…</TiltLayer></TiltCard> a single card
*/
export type TiltCardsVariant = 'features' | 'product' | 'profile' | 'pricing' | 'ticket';
export interface TiltItem {
title: string;
text?: string;
icon?: IconName;
href?: string;
/** product: price; pricing: price per period; profile: role */
meta?: string;
/** pricing: what the plan includes; ticket: facts like date, place, seat */
points?: string[];
/** pricing: the recommended plan */
featured?: boolean;
/** a short mark on the card, e.g. "New" or "Most chosen" */
badge?: string;
}
export interface TiltCardsProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: TiltCardsVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
items: TiltItem[];
/** product: product pictures; profile: portraits; ticket: the code or artwork on the ticket */
media?: React.ReactNode[];
/** pricing, ticket: the button text */
action?: { label: string; href?: string };
/** Degrees per full lean. Default 10. */
strength?: number;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { more: 'Learn more', add: 'Add to cart', per: 'per month', admit: 'Admit one', seat: 'Seat' },
de: { more: 'Mehr erfahren', add: 'In den Warenkorb', per: 'pro Monat', admit: 'Einlass für eine Person', seat: 'Platz' },
};
/* ------------------------------------------------------------------ */
/* The single card */
/* ------------------------------------------------------------------ */
export interface TiltCardProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
/** The whole card becomes a link */
href?: string;
/** Degrees per full lean. Default 10. */
strength?: number;
/** The light that follows the pointer. Default true. */
glare?: boolean;
/** Classes of the card face (background, border, padding, radius) */
faceClassName?: string;
as?: 'div' | 'article' | 'li';
}
/** Lifts its content off the card by `depth` px while the card leans. Flat when nothing leans. */
export function TiltLayer({ depth = 24, className, children }: { depth?: number; className?: string; children: React.ReactNode }) {
return (
<div className={cn('[transform-style:preserve-3d] transition-transform duration-500 motion-reduce:transition-none', className)} style={{ transform: `translateZ(calc(var(--tilt-on, 0) * ${depth}px))`, transitionTimingFunction: ease3d }}>
{children}
</div>
);
}
const FOCUS = 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-primary';
/** A card that leans toward the pointer. Put TiltLayer inside for contents at their own depth. */
export function TiltCard({ href, strength = 10, glare = true, faceClassName, as: Tag = 'div', className, children, ...rest }: TiltCardProps) {
const tilt = usePointerTilt<HTMLDivElement>();
const body = (
<div
className={cn('relative h-full [transform-style:preserve-3d] transition-transform duration-500 group-hover/tilt:[--tilt-on:1] group-focus-within/tilt:[--tilt-on:1] motion-reduce:transition-none', faceClassName)}
style={{
transform: `rotateX(calc(var(--tilt-y, 0) * ${-round(strength * 0.8, 2)}deg)) rotateY(calc(var(--tilt-x, 0) * ${strength}deg)) translateZ(calc(var(--tilt-on, 0) * 10px))`,
transitionTimingFunction: ease3d,
}}
>
{children}
{glare && <Glare strength={0.4} className="opacity-0 transition-opacity duration-500 group-hover/tilt:opacity-100 group-focus-within/tilt:opacity-60" />}
</div>
);
return (
<Tag {...rest} className={cn('group/tilt relative [perspective:1000px]', className)}>
<div ref={tilt.ref} {...tilt.handlers} className="h-full [transform-style:preserve-3d]">
{href ? (
<a href={href} className={cn('block h-full cursor-pointer rounded-[inherit] [transform-style:preserve-3d]', FOCUS)}>
{body}
</a>
) : (
body
)}
</div>
</Tag>
);
}
/* ------------------------------------------------------------------ */
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] transition-shadow duration-500 group-hover/tilt:shadow-[var(--highlight),var(--shadow-lg)]';
function Head({ eyebrow, title, description, center = true }: Pick<TiltCardsProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean }) {
if (!title) return null;
return (
<Reveal className={cn('max-w-[42rem]', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className={cn('mt-4 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</Reveal>
);
}
/** A foil sheen in the accent for the profile cards, turning with the light */
function Foil() {
return (
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-40 mix-blend-overlay transition-opacity duration-500 group-hover/tilt:opacity-80 dark:mix-blend-soft-light"
style={{
background:
'linear-gradient(calc(115deg + var(--tilt-x, 0) * 40deg), transparent 20%, color-mix(in oklab, var(--primary) 70%, white) 38%, transparent 50%, color-mix(in oklab, var(--primary) 50%, white) 64%, transparent 80%)',
backgroundSize: '200% 200%',
backgroundPosition: 'var(--glare-x, 30%) var(--glare-y, 30%)',
}}
/>
);
}
/** Cards that lean toward the pointer, with contents floating at their own depth. */
export function TiltCards({ variant = 'features', eyebrow, title, description, items, media = [], action, strength = 10, labels, className, ...rest }: TiltCardsProps) {
const t = useTexts(TEXTS, labels);
const pic = (i: number) => (media.length ? media[i % media.length] : null);
if (variant === 'ticket') {
const item = items[0];
if (!item) return null;
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div className="kit-container grid grid-cols-1 items-center gap-14 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-20">
<div>
<Head eyebrow={eyebrow} title={title} description={description} center={false} />
{action && (
<div className="mt-8 max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
)}
</div>
<TiltCard strength={strength + 4} className="mx-auto w-full max-w-[26rem]" faceClassName="rounded-3xl">
<div className="relative overflow-hidden rounded-3xl bg-primary text-primary-foreground shadow-[var(--shadow-lg)]">
<span aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_80%_at_0%_0%,color-mix(in_oklab,white_22%,transparent),transparent_60%)]" />
<div className="relative p-7 sm:p-8">
<div className="flex items-start justify-between gap-4">
<p className="text-[12px] font-medium uppercase tracking-[0.12em] opacity-90">{t.admit}</p>
{item.badge && <span className="rounded-md bg-[color-mix(in_oklab,white_20%,transparent)] px-2 py-1 text-[12px] font-semibold">{item.badge}</span>}
</div>
<h3 className="mt-10 text-balance text-[clamp(1.9rem,1.4rem+1.6vw,2.6rem)] font-semibold leading-[1.02] tracking-[-0.03em]">{item.title}</h3>
{item.text && <p className="mt-3 text-[15px] opacity-85">{item.text}</p>}
</div>
{/* perforation: two notches and a dashed line */}
<div aria-hidden data-clip-ok className="relative h-6">
<span className="absolute -left-3 top-0 size-6 rounded-full bg-background" />
<span className="absolute -right-3 top-0 size-6 rounded-full bg-background" />
<span className="absolute inset-x-6 top-1/2 border-t-2 border-dashed border-[color-mix(in_oklab,white_35%,transparent)]" />
</div>
<div className="relative grid grid-cols-[minmax(0,1fr)_auto] items-end gap-6 p-7 pt-5 sm:p-8 sm:pt-5">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-[13px]">
{(item.points ?? []).map((pt) => {
const [k, ...v] = pt.split('|');
return (
<div key={pt} className="min-w-0">
<dt className="opacity-85">{v.length ? k : ''}</dt>
<dd className="text-pretty font-semibold [overflow-wrap:anywhere]">{v.length ? v.join('|') : k}</dd>
</div>
);
})}
</dl>
<div className="size-20 overflow-hidden rounded-xl bg-white p-1.5 text-black [&>*]:size-full">{pic(0)}</div>
</div>
</div>
</TiltCard>
</div>
</section>
);
}
const cols = variant === 'pricing' ? 'md:grid-cols-3' : items.length >= 4 ? 'sm:grid-cols-2 lg:grid-cols-4' : items.length === 3 ? (variant === 'features' ? 'sm:grid-cols-2 lg:grid-cols-3 sm:max-lg:[&>li:last-child]:col-span-2' : 'sm:grid-cols-3') : 'sm:grid-cols-2';
return (
<section {...rest} className={cn('relative py-20 sm:py-28', className)}>
<div className="kit-container">
<Head eyebrow={eyebrow} title={title} description={description} />
<Stagger as="ul" className={cn('grid grid-cols-1 gap-5 lg:gap-6', title && 'mt-12 sm:mt-16', cols, variant === 'pricing' && 'items-stretch md:items-center')}>
{items.map((it, i) => {
if (variant === 'features')
return (
<TiltCard key={i} as="li" href={it.href} strength={strength} className="rounded-2xl" faceClassName={cn(CARD, 'p-7 sm:p-8')}>
<span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-500 group-hover/tilt:opacity-100" style={{ background: 'radial-gradient(60% 60% at var(--glare-x, 50%) var(--glare-y, 50%), color-mix(in oklab, var(--primary) 12%, transparent), transparent 70%)' }} />
<TiltLayer depth={46}>
<span className="flex size-12 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary shadow-[var(--highlight)]">
<Icon name={it.icon ?? 'sparkle'} size={22} />
</span>
</TiltLayer>
<TiltLayer depth={28} className="mt-8">
<h3 className="text-h3 font-semibold text-foreground">{it.title}</h3>
</TiltLayer>
<TiltLayer depth={14}>
{it.text && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{it.text}</p>}
{it.href && (
<span className="mt-6 inline-flex items-center gap-1.5 text-[14px] font-medium text-primary">
{t.more}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/tilt:translate-x-1" />
</span>
)}
</TiltLayer>
</TiltCard>
);
if (variant === 'product')
return (
<TiltCard key={i} as="li" strength={strength} className="rounded-2xl" faceClassName={cn(CARD, 'flex h-full flex-col p-4')}>
<div className="relative aspect-[4/3.4] rounded-xl bg-[color-mix(in_oklab,var(--primary)_6%,var(--surface))] [transform-style:preserve-3d]">
<TiltLayer depth={60} className="absolute inset-[8%]">
<div className="size-full overflow-hidden rounded-lg shadow-[var(--shadow-lg)] [&>*]:size-full">{pic(i)}</div>
</TiltLayer>
{it.badge && (
<TiltLayer depth={70} className="absolute left-3 top-3">
<span className="rounded-md bg-primary px-2 py-1 text-[12px] font-semibold text-primary-foreground shadow-[var(--shadow-sm)]">{it.badge}</span>
</TiltLayer>
)}
</div>
<TiltLayer depth={20} className="flex flex-1 flex-col px-2 pb-1 pt-5">
<div className="flex items-baseline justify-between gap-3">
<h3 className="min-w-0 text-[17px] font-semibold text-foreground">
{it.href ? (
<a href={it.href} className={cn('cursor-pointer rounded-sm hover:underline', FOCUS)}>
{it.title}
</a>
) : (
it.title
)}
</h3>
{it.meta && <span className="shrink-0 text-[16px] font-semibold tabular-nums text-foreground">{it.meta}</span>}
</div>
{it.text && <p className="mt-1.5 text-[14px] text-muted-foreground">{it.text}</p>}
<div className="mt-auto pt-5">
<Button variant="secondary" size="sm" iconLeft="cart" className="w-full">
{action?.label ?? t.add}
</Button>
</div>
</TiltLayer>
</TiltCard>
);
if (variant === 'profile')
return (
<TiltCard key={i} as="li" strength={strength + 2} className="rounded-2xl" faceClassName={cn(CARD, 'overflow-visible p-3')}>
<div className="relative aspect-[4/5] overflow-hidden rounded-xl bg-surface [&>*]:size-full">{pic(i)}</div>
<Foil />
<TiltLayer depth={34} className="px-3 pb-3 pt-5">
<h3 className="text-[18px] font-semibold text-foreground">{it.title}</h3>
{it.meta && <p className="mt-0.5 text-[14px] text-muted-foreground">{it.meta}</p>}
</TiltLayer>
{it.text && (
<TiltLayer depth={18} className="px-3 pb-2">
<p className="flex items-center gap-2 border-t border-border pt-4 text-[13px] text-subtle-foreground">
<Icon name={it.icon ?? 'map-pin'} size={14} />
{it.text}
</p>
</TiltLayer>
)}
</TiltCard>
);
// pricing
const featured = Boolean(it.featured);
return (
<TiltCard
key={i}
as="li"
strength={featured ? strength * 0.7 : strength}
className={cn('rounded-2xl', featured && 'md:z-10 md:scale-[1.04]')}
faceClassName={cn(CARD, 'flex h-full flex-col p-7 sm:p-8', featured && 'border-primary shadow-[var(--highlight),var(--shadow-lg),0_0_0_1px_var(--primary)]')}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-[17px] font-semibold text-foreground">{it.title}</h3>
{it.badge && <span className="rounded-md bg-primary px-2 py-1 text-[12px] font-semibold text-primary-foreground">{it.badge}</span>}
</div>
{it.text && <p className="mt-2 text-[14px] text-muted-foreground">{it.text}</p>}
<TiltLayer depth={featured ? 44 : 30} className="mt-7">
<p className="flex items-baseline gap-2">
<span className="text-[2.6rem] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">{it.meta}</span>
<span className="text-[14px] text-muted-foreground">{t.per}</span>
</p>
</TiltLayer>
<ul className="mt-7 grid gap-3 border-t border-border pt-6">
{(it.points ?? []).map((pt) => (
<li key={pt} className="flex gap-2.5 text-[14.5px] text-foreground">
<Icon name="check" size={16} className="mt-0.5 shrink-0 text-primary" />
{pt}
</li>
))}
</ul>
<TiltLayer depth={20} className="mt-auto pt-8">
<Button href={it.href ?? action?.href} variant={featured ? 'primary' : 'secondary'} className="w-full">
{action?.label ?? it.title}
</Button>
</TiltLayer>
</TiltCard>
);
})}
</Stagger>
</div>
</section>
);
}Frequent questions
How do I install Tilt cards?
With aiw add @ai-website/tilt-card. The command puts components/ui/tilt-card.tsx into your project, the code is yours from then on. It brings along section, button, icon, reveal, motion and depth. It needs no further npm packages.
Which variants does Tilt cards have?
5 variants, Feature cards in depth, Products rising out, Portraits with foil, Plans in depth and Event ticket. You pick one with the variant prop, for example variant="product".
Can I translate the texts of Tilt cards?
Yes. Tilt cards carries 5 texts of its own in English and German, such as “Learn more”, “Add to cart” and “per month”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Tilt cards?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tilt cards with aiw_components and install it with aiw_add @ai-website/tilt-card. Name the variant in your prompt, for example variant="features", and the assistant writes your content straight into the props.