Skip to content
UI Kit
Accent color

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.

5 variantsOne of 6 components in 3D surfaces

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

Feature cards in depthNew in 1.2

Icon, title and text float on three depths above the card while it leans. The whole card is a link, a soft accent light follows the pointer.

Products rising outNew in 1.2

The product picture rises out of its card while it leans, the badge floats highest. Name, price and an add button sit on the card itself.

Portraits with foilNew in 1.2

Team cards with a foil sheen in the accent that turns with the light. Name and role float above the portrait while the card leans.

Plans in depthNew in 1.2

Three plans, the recommended one stands forward and leans less. The price floats above every card, the button stays on it.

Event ticketNew in 1.2

One ticket in the accent with a perforated stub and a code, leaning stronger than a card. The text and the booking button stand beside it.

Details

What Tilt cards brings along, read from the registry and the source.

Category
3D surfaces
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/tilt-card.tsx
Brings along
utilskitsectionbuttoniconrevealmotiondepth
npm packages
None
Languages
English and German, 5 texts of its own
Studio
Adjust in code
In collections
3D and motion

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'>.

  • variantoptional
    TiltCardsVariant

    featuresproductprofilepricingticket

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsrequired
    TiltItem[]
  • mediaoptional
    React.ReactNode[]

    product: product pictures; profile: portraits; ticket: the code or artwork on the ticket

  • actionoptional
    { label: string; href?: string }

    pricing, ticket: the button text

  • strengthoptional
    number

    Degrees 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
moreEnglish Learn moreGerman Mehr erfahren
addEnglish Add to cartGerman In den Warenkorb
perEnglish per monthGerman pro Monat
admitEnglish Admit oneGerman Einlass für eine Person
seatEnglish SeatGerman 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
components/ui/tilt-card.tsx
'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.

Often searched

Jump to

Copied to the clipboard