Zum Inhalt springen
UI Kit
Accent

Neigekarten mit 3D Hover Effekt

Karten, die sich wie Glas zum Zeiger neigen, mit einem Licht, das folgt, und Inhalten in eigener Tiefe. Auch der Fokus hebt sie an, auf Touchscreens und bei reduzierter Bewegung liegen sie ruhig und flach.

5 VariantsEine von 6 Components in 3D-Flächen

Karten, die sich dir zuneigen

Eine Neigekarte neigt sich wie Glas zum Zeiger, ein weiches Licht folgt, und ihre Inhalte schweben in eigener Tiefe. Der Tastaturfokus hebt sie genauso an, auf Touchscreens und bei reduzierter Bewegung liegen die Karten ruhig und flach, alle Besucher lesen also denselben Inhalt.

„Feature-Karten mit Tiefe“ lassen Icon, Titel und Text in drei Ebenen schweben, die ganze Karte ist ein Link. Bei „Produkte, die herauskommen“ hebt sich das Produktbild aus der Karte, das Badge schwebt am höchsten, Name, Preis und Knopf bleiben auf der Karte. „Porträts mit Folie“ geben Teamkarten einen Akzentschimmer, der sich mit dem Licht dreht. „Tarife mit Tiefe“ stellen den empfohlenen von drei Tarifen nach vorn. Die „Eintrittskarte“ neigt ein Ticket mit perforiertem Abriss und Code neben dem Buchungstext.

Die Stärke der Neigung ist ein Prop, wenige Grad wirken hochwertig. Neigekarten passen zu Rastern mit drei oder vier gleichwertigen Inhalten. Leg den wichtigen Text flach auf die Karte, die schwebenden Ebenen sind der Akzent.

Variants

Feature-Karten mit TiefeNeu in 1.2

Icon, Titel und Text schweben in drei Tiefen über der Karte, während sie sich neigt. Die ganze Karte ist ein Link, ein weiches Akzentlicht folgt dem Zeiger.

Produkte, die herauskommenNeu in 1.2

Das Produktbild hebt sich aus der Karte, während sie sich neigt, das Badge schwebt am höchsten. Name, Preis und ein Knopf zum Hinzufügen liegen auf der Karte.

Porträts mit FolieNeu in 1.2

Teamkarten mit einem Folienschimmer im Akzent, der sich mit dem Licht dreht. Name und Rolle schweben über dem Porträt, während die Karte sich neigt.

Tarife mit TiefeNeu in 1.2

Drei Tarife, der empfohlene steht vorn und neigt sich weniger. Der Preis schwebt über jeder Karte, der Knopf bleibt darauf.

EintrittskarteNeu in 1.2

Eine Eintrittskarte im Akzent mit perforiertem Abschnitt und Code, stärker geneigt als eine Karte. Text und Buchungsknopf stehen daneben.

Details

Was Neigekarten mitbringt, aus Registry und Quellcode gelesen.

Kategorie
3D-Flächen
Bereich
Visuals
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/tilt-card.tsx
Bringt mit
utilskitsectionbuttoniconrevealmotiondepth
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 5 eigene Texte
Studio
Im Code anpassen
In Sammlungen
3D und Bewegung

Props

Die Schnittstelle TiltCardsProps aus components/ui/tilt-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.

  • variantoptional
    TiltCardsVariant

    featuresproductprofilepricingticket

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Neigekarten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 5 Texte anzeigen
moreEnglisch Learn moreDeutsch Mehr erfahren
addEnglisch Add to cartDeutsch In den Warenkorb
perEnglisch per monthDeutsch pro Monat
admitEnglisch Admit oneDeutsch Einlass für eine Person
seatEnglisch SeatDeutsch Platz

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 338 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Neigekarten?

Mit aiw add @ai-website/tilt-card. Der Befehl legt die Datei components/ui/tilt-card.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, icon, reveal, motion und depth. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Neigekarten?

5 Varianten, Feature-Karten mit Tiefe, Produkte, die herauskommen, Porträts mit Folie, Tarife mit Tiefe und Eintrittskarte. Du wählst sie mit der Prop variant, zum Beispiel variant="product".

Kann ich die Texte von Neigekarten übersetzen?

Ja. Neigekarten bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Mehr erfahren“, „In den Warenkorb“ und „pro Monat“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Neigekarten einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Neigekarten mit aiw_components und installieren sie mit aiw_add @ai-website/tilt-card. Nenn im Prompt die Variante, etwa variant="features", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert