Zum Inhalt springen
UI Kit
Accent

3D Karussell und Kartenstapel

Karten in echter Tiefe, ein Fächer in der Hand, ein Stapel zum Austeilen, eine Kaskade aus Fenstern, ein drehender Ring und eine Trommel zum Durchblättern. Echtes HTML auf jeder Karte, Pfeiltasten überall, der Ring hält bei Hover und Fokus an.

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

Karten in echter Tiefe

Ein Kartenstapel zeigt mehrere Inhalte auf einmal, während einer vorn steht. Diese fünf Anordnungen tragen echtes HTML auf jeder Karte in echter CSS-Tiefe, alle lassen sich per Pfeiltasten bedienen, und der Stapel mit Zitaten nennt jede neue Karte in einer Live-Region.

„Fächer in der Hand“ kippt Karten in den Raum und hebt die unter Zeiger oder Fokus heraus. Beim „Stapel mit Zitaten“ wandert die vordere Karte per Weiter nach hinten. „Kaskade aus Fenstern“ holt ein App-Fenster über eine Reiterliste daneben nach vorn. Das „Ring-Karussell“ dreht Karten auf einem Ring im Raum, hält bei Hover und Fokus an und hat einen Pause-Knopf. Die „Trommel zum Durchblättern“ dreht Schritte nach oben wie einen Karteikasten, per Knöpfe, Nummern und Pfeiltasten.

Fächer und Kaskade deuten Vielfalt an, der Stapel zeigt Zitate eines nach dem anderen. Das Ring-Karussell dreht sich selbst, halte seine Karten kurz und den Pause-Knopf sichtbar. Die Trommel passt zu Schritten, die aufeinander folgen.

Variants

Fächer in der HandNeu in 1.2

Karten als Fächer gehalten, nach hinten in den Raum gekippt. Die Karte unter Zeiger oder Fokus hebt sich heraus, der ganze Fächer neigt sich mit der Maus.

Stapel mit ZitatenNeu in 1.2

Zitate in der Tiefe gestapelt. Weiter schickt die vordere Karte nach unten weg, sie reiht sich hinten ein. Auch die Pfeiltasten teilen aus, ein Live-Bereich nennt die Karte.

Kaskade aus FensternNeu in 1.2

App-Fenster, die in den Raum kaskadieren. Eine Tab-Liste daneben holt eines nach vorn, die anderen treten zurück und verblassen.

Ring-KarussellNeu in 1.2

Karten auf einem Ring, der sich im Raum dreht, alle paar Sekunden einen Schritt, solange er zu sehen ist. Er hält bei Hover und Fokus, ein Pausenknopf hält ihn ganz an.

Trommel zum DurchblätternNeu in 1.2

Schritte auf einer Trommel, die sich nach oben dreht wie ein Karteikasten. Knöpfe, die Nummern und die Pfeiltasten bewegen sie, die Nachbarn bleiben im Blick.

Details

Was Kartenstapel in 3D 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/card-stack-3d.tsx
Bringt mit
utilskitsectioniconrevealdepthmockup
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
Studio
Im Code anpassen
In Sammlungen
3D und Bewegung

Props

Die Schnittstelle CardStack3DProps aus components/ui/card-stack-3d.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
    CardStack3DVariant

    fandeckcascaderingdrum

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsPflicht
    Stack3DItem[]
  • mediaoptional
    React.ReactNode[]

    Pictures on the cards (fan, cascade, ring)

  • autoPlayoptional
    boolean

    ring: turn on its own while on screen. Default true.

  • intervaloptional
    number

    ring: seconds per card. Default 4.

  • screenWidthoptional
    number

    cascade: the width the window screens render at before they scale into the window. Default 620.

Eingebaute Texte

Diese Texte schreibt Kartenstapel in 3D selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 7 Texte anzeigen
prevEnglisch Previous cardDeutsch Vorige Karte
nextEnglisch Next cardDeutsch Nächste Karte
ofEnglisch ofDeutsch von
pauseEnglisch Pause turningDeutsch Drehen anhalten
playEnglisch Start turningDeutsch Drehen starten
cardsEnglisch CardsDeutsch Karten
openEnglisch OpenDeutsch Öffnen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kartenstapel in 3D reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Setzt aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-orientation, aria-roledescription und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • 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, 479 Zeilen
components/ui/card-stack-3d.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { ease3d, useActive, usePointerTilt } from '@/components/ui/depth';
import { MockupScreen } from '@/components/ui/mockup';

/*
 * Cards arranged in real depth: a fan held in a hand, a deck to deal from, a cascade of windows, a
 * ring that turns and a drum to flip through. Every arrangement is pure CSS 3D, the cards stay live
 * HTML (links, text, pictures) and only transform and opacity move.
 *
 * Every variant can be used with the keyboard: arrow keys, Home and End move through the cards,
 * the buttons say what they do, and a polite live region names the card in front. Cards out of
 * view are inert. The ring turns on its own only while on screen, with a pause button, and stops
 * on hover and focus.
 *
 * Without JavaScript the first card stands in front of a still arrangement; with reduced motion
 * the cards change place without travelling.
 *
 *   fan       cards held as a fan in a hand, the one under the pointer or focus lifts out
 *   deck      a deck in depth, the front card leaves downward and goes to the back
 *   cascade   windows cascading into the room, a list beside them brings one to the front
 *   ring      a carousel on a ring that turns in space
 *   drum      cards on a drum that flips upward, like an index box
 */

export type CardStack3DVariant = 'fan' | 'deck' | 'cascade' | 'ring' | 'drum';

export interface Stack3DItem {
  title: string;
  text?: string;
  /** a short line above the title, a name below a quote, a date */
  meta?: string;
  icon?: IconName;
  href?: string;
}

export interface CardStack3DProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: CardStack3DVariant;
  eyebrow?: string;
  title?: React.ReactNode;
  description?: string;
  items: Stack3DItem[];
  /** Pictures on the cards (fan, cascade, ring) */
  media?: React.ReactNode[];
  /** ring: turn on its own while on screen. Default true. */
  autoPlay?: boolean;
  /** ring: seconds per card. Default 4. */
  interval?: number;
  /** cascade: the width the window screens render at before they scale into the window. Default 620. */
  screenWidth?: number;
  labels?: Partial<(typeof TEXTS)['en']>;
}

const TEXTS = {
  en: { prev: 'Previous card', next: 'Next card', of: 'of', pause: 'Pause turning', play: 'Start turning', cards: 'Cards', open: 'Open' },
  de: { prev: 'Vorige Karte', next: 'Nächste Karte', of: 'von', pause: 'Drehen anhalten', play: 'Drehen starten', cards: 'Karten', open: 'Öffnen' },
};

const FOCUS = 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-primary';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]';
const MOVE = 'transition-[transform,opacity] duration-700 motion-reduce:transition-none';

function Head({ eyebrow, title, description, center = true }: Pick<CardStack3DProps, '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>
  );
}

function NavButton({ dir, onClick, label }: { dir: 'prev' | 'next' | 'up' | 'down'; onClick: () => void; label: string }) {
  const icon = dir === 'prev' ? 'arrow-left' : dir === 'next' ? 'arrow-right' : dir === 'up' ? 'arrow-up' : 'arrow-down';
  return (
    <button type="button" onClick={onClick} aria-label={label} className={cn('flex size-11 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)] transition-[background-color,transform] duration-200 hover:bg-accent active:scale-95', FOCUS)}>
      <Icon name={icon} size={18} />
    </button>
  );
}

/** Keyboard on the group: arrows, Home and End */
function keysFor(n: number, cur: number, go: (i: number) => void, vertical = false) {
  return (e: React.KeyboardEvent) => {
    const back = vertical ? 'ArrowUp' : 'ArrowLeft';
    const fwd = vertical ? 'ArrowDown' : 'ArrowRight';
    const to = e.key === back ? (cur - 1 + n) % n : e.key === fwd ? (cur + 1) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
    if (to < 0) return;
    e.preventDefault();
    go(to);
  };
}

/* ------------------------------------------------------------------ */

/** Cards arranged in real depth: fan, deck, cascade, ring and drum. */
export function CardStack3D({ variant = 'fan', eyebrow, title, description, items, media = [], autoPlay = true, interval = 4, screenWidth = 620, labels, className, ...rest }: CardStack3DProps) {
  const t = useTexts(TEXTS, labels);
  const id = useId();
  const n = Math.max(1, items.length);
  const [cur, setCur] = useState(0);
  const go = (i: number | ((c: number) => number)) => setCur((c) => ((typeof i === 'function' ? i(c) : i) + n) % n);
  const pic = (i: number) => (media.length ? media[i % media.length] : null);
  const tilt = usePointerTilt<HTMLDivElement>({ focus: false });
  const live = items[cur] ? `${items[cur].title}, ${cur + 1} ${t.of} ${n}` : '';

  const [lift, setLift] = useState(-1);

  /* ---------- ring: auto turn while on screen, paused on hover, focus and by the button ---------- */
  const [watch, active] = useActive<HTMLDivElement>();
  const [paused, setPaused] = useState(false);
  const [held, setHeld] = useState(false);
  useEffect(() => {
    if (variant !== 'ring' || !autoPlay || !active || paused || held) return;
    const timer = window.setInterval(() => setCur((c) => (c + 1) % n), interval * 1000);
    return () => window.clearInterval(timer);
  }, [variant, autoPlay, active, paused, held, n, interval]);
  // The ring keeps turning the same way: count whole turns, not only the index
  const turns = useRef({ last: 0, total: 0 });
  if (variant === 'ring' && cur !== turns.current.last) {
    let d = cur - turns.current.last;
    if (d > n / 2) d -= n;
    if (d < -n / 2) d += n;
    turns.current.total += d;
    turns.current.last = cur;
  }

  /* ---------- deck: the front card leaves downward, then joins the back ---------- */
  const [leaving, setLeaving] = useState(-1);
  const deal = (dir: 1 | -1) => {
    if (dir === 1) {
      setLeaving(cur);
      window.setTimeout(() => setLeaving(-1), 380);
    }
    go((c) => c + dir);
  };

  const status = (
    <p aria-live="polite" className="sr-only">
      {live}
    </p>
  );

  /* ------------------------------------------------------------------ */
  if (variant === 'fan') {
    const spread = n > 1 ? Math.min(11, 48 / (n - 1)) : 0;
    // Phones hold the fan tighter, so the outer cards stay on screen
    // The caption names the lifted card, or the middle one while nothing is lifted
    const shown = lift >= 0 ? lift : Math.floor((n - 1) / 2);
    const cap = items[shown];
    return (
      <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          <Head eyebrow={eyebrow} title={title} description={description} />
          <div ref={tilt.ref} {...tilt.handlers} className="relative mx-auto mt-10 w-full max-w-[56rem] [perspective:1600px] sm:mt-14">
            <ul
              className="relative mx-auto aspect-[1.25] w-full max-w-[46rem] [--fan:min(5deg,var(--fan-lg))] [transform-style:preserve-3d] sm:aspect-[1.9] sm:[--fan:var(--fan-lg)]"
              style={{ ['--fan-lg' as string]: `${round(spread, 2)}deg`, transform: 'rotateX(calc(8deg - var(--tilt-y, 0) * 6deg)) rotateY(calc(var(--tilt-x, 0) * 8deg))' }}
              onPointerLeave={() => setLift(-1)}
            >
              {items.map((it, i) => {
                const k = i - (n - 1) / 2;
                // At rest the middle card stands lifted, so the caption always names the card in front
                const on = shown === i;
                const Tag = it.href ? 'a' : 'div';
                return (
                  <li key={i} className="absolute bottom-[6%] left-1/2 w-[34%] -translate-x-1/2 [transform-style:preserve-3d] sm:w-[30%]" style={{ zIndex: on ? 50 : i }}>
                    <Tag
                      {...(it.href ? { href: it.href } : { tabIndex: 0 })}
                      aria-label={[it.title, it.meta].filter(Boolean).join(', ')}
                      onPointerEnter={() => setLift(i)}
                      onFocus={() => setLift(i)}
                      onBlur={() => setLift(-1)}
                      data-clip-ok
                      className={cn('block aspect-[3/4] cursor-pointer overflow-hidden [&>*]:size-full', CARD, FOCUS, MOVE)}
                      style={{
                        transformOrigin: '50% 240%',
                        transform: `rotateZ(calc(var(--fan) * ${round(k, 2)})) translateZ(${round(i * 2 + (on ? 60 : 0), 1)}px) translateY(${on ? -10 : 0}%)`,
                        transitionTimingFunction: ease3d,
                      }}
                    >
                      {pic(i)}
                    </Tag>
                  </li>
                );
              })}
            </ul>
            <span aria-hidden className="pointer-events-none absolute inset-x-[20%] bottom-[2%] h-10 rounded-[50%] bg-[color-mix(in_oklab,var(--foreground)_16%,transparent)] blur-2xl dark:bg-[color-mix(in_oklab,black_60%,transparent)]" />
          </div>
          {cap && (
            <p aria-hidden className="mt-4 text-center">
              <span key={shown} className="inline-block animate-[kit-rise_0.5s_var(--ease-kit)_both] motion-reduce:animate-none">
                <span className="block text-[18px] font-semibold text-foreground">{cap.title}</span>
                {cap.meta && <span className="block text-[14px] text-muted-foreground">{cap.meta}</span>}
              </span>
            </p>
          )}
        </div>
      </section>
    );
  }

  /* ------------------------------------------------------------------ */
  if (variant === 'deck') {
    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,0.9fr)_minmax(0,1.1fr)] lg:gap-20">
          <div>
            <Head eyebrow={eyebrow} title={title} description={description} center={false} />
            <div className="mt-8 flex items-center gap-3">
              <NavButton dir="prev" onClick={() => deal(-1)} label={t.prev} />
              <NavButton dir="next" onClick={() => deal(1)} label={t.next} />
              <span className="ml-2 text-[14px] tabular-nums text-muted-foreground">
                {cur + 1} / {n}
              </span>
            </div>
          </div>
          <div role="group" aria-roledescription="carousel" aria-label={t.cards} tabIndex={0} onKeyDown={keysFor(n, cur, (i) => (i === (cur + 1) % n ? deal(1) : i === (cur - 1 + n) % n ? deal(-1) : go(i)))} className={cn('relative rounded-2xl [perspective:1400px]', FOCUS)}>
            <div className="relative grid pt-12 [transform-style:preserve-3d]">
              {items.map((it, i) => {
                const pos = (i - cur + n) % n;
                const out = i === leaving;
                const shown = pos < 4;
                return (
                  <article
                    key={i}
                    aria-hidden={pos !== 0}
                    inert={pos !== 0 ? true : undefined}
                    className={cn('[grid-area:1/1]', CARD, 'flex min-h-[17rem] flex-col p-7 sm:p-9', MOVE)}
                    style={{
                      zIndex: out ? 60 : 40 - pos,
                      opacity: out ? 0 : shown ? 1 - pos * 0.22 : 0,
                      filter: pos > 0 ? `brightness(${round(1 - pos * 0.08, 2)})` : undefined,
                      transform: out ? 'translate3d(0, 60%, 60px) rotateX(-18deg)' : `translate3d(0, ${-pos * 20}px, ${-pos * 56}px)`,
                      transitionTimingFunction: ease3d,
                    }}
                  >
                    <Icon name={it.icon ?? 'quote'} size={26} className="text-primary" />
                    <p className="mt-5 text-pretty text-[clamp(1.05rem,0.95rem+0.5vw,1.3rem)] font-medium leading-snug text-foreground">{it.text}</p>
                    <div className="mt-auto flex items-center justify-between gap-4 pt-8">
                      <div className="min-w-0">
                        <p className="truncate text-[15px] font-semibold text-foreground">{it.title}</p>
                        {it.meta && <p className="truncate text-[13.5px] text-muted-foreground">{it.meta}</p>}
                      </div>
                      {media.length > 0 && <div className="size-11 shrink-0 overflow-hidden rounded-full [&>*]:size-full">{pic(i)}</div>}
                    </div>
                  </article>
                );
              })}
            </div>
          </div>
        </div>
        {status}
      </section>
    );
  }

  /* ------------------------------------------------------------------ */
  if (variant === 'cascade') {
    return (
      <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          <Head eyebrow={eyebrow} title={title} description={description} />
          <div className="mt-12 grid grid-cols-1 items-center gap-10 sm:mt-16 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)] lg:gap-14">
            <div role="tablist" aria-orientation="vertical" aria-label={t.cards} onKeyDown={keysFor(n, cur, (i) => {
                go(i);
                document.getElementById(`${id}t${i}`)?.focus();
              }, true)}
              className="grid gap-2 max-lg:order-2">
              {items.map((it, i) => {
                const on = i === cur;
                return (
                  <button
                    key={i}
                    id={`${id}t${i}`}
                    type="button"
                    role="tab"
                    aria-selected={on}
                    aria-controls={`${id}p${i}`}
                    tabIndex={on ? 0 : -1}
                    onClick={() => go(i)}
                    className={cn('group flex min-h-11 cursor-pointer items-start gap-4 rounded-xl border p-4 text-left transition-colors duration-300', FOCUS, on ? 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'border-transparent hover:bg-accent')}
                  >
                    <span className={cn('flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                      <Icon name={it.icon ?? 'layers'} size={17} />
                    </span>
                    <span className="min-w-0">
                      <span className="block text-[15.5px] font-semibold text-foreground">{it.title}</span>
                      {it.text && <span className={cn('mt-1 block text-[14px] leading-relaxed text-muted-foreground', !on && 'max-lg:hidden')}>{it.text}</span>}
                    </span>
                  </button>
                );
              })}
            </div>
            <div className="relative [perspective:1800px] max-lg:order-1 max-lg:mb-10">
              <div className="relative grid w-[82%] pt-[8%] [transform-style:preserve-3d]" style={{ transform: 'rotateY(-16deg) rotateX(5deg)', transformOrigin: '0% 50%' }}>
                {items.map((it, i) => {
                  const pos = (i - cur + n) % n;
                  return (
                    <div
                      key={i}
                      id={`${id}p${i}`}
                      role="tabpanel"
                      aria-labelledby={`${id}t${i}`}
                      aria-hidden={pos !== 0}
                      inert={pos !== 0 ? true : undefined}
                      className={cn('[grid-area:1/1] overflow-hidden', CARD, MOVE)}
                      style={{
                        zIndex: 40 - pos,
                        opacity: pos > 3 ? 0 : 1,
                        transform: `translate3d(${pos * 5}%, ${-pos * 7}%, ${-pos * 80}px)`,
                        transitionTimingFunction: ease3d,
                      }}
                    >
                      <div className="flex h-8 items-center gap-1.5 border-b border-border bg-surface px-3" aria-hidden>
                        {[0, 1, 2].map((d) => (
                          <span key={d} className="size-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_16%,var(--surface))]" />
                        ))}
                        <span className="ml-3 truncate text-[11px] text-muted-foreground">{it.meta ?? it.title}</span>
                      </div>
                      <div className="relative aspect-[16/10] overflow-hidden bg-background">
                        {/* the screen renders at desktop width and scales into the window, so nothing reflows or spills */}
                        <MockupScreen viewport={screenWidth} guess={480}>
                          {pic(i)}
                        </MockupScreen>
                      </div>
                      {/* windows further back fade into the page */}
                      <span aria-hidden className="pointer-events-none absolute inset-0 bg-background transition-opacity duration-700" style={{ opacity: round(Math.min(0.75, pos * 0.24), 2) }} />
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        </div>
      </section>
    );
  }

  /* ------------------------------------------------------------------ */
  if (variant === 'ring') {
    const step = 360 / n;
    return (
      <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          <Head eyebrow={eyebrow} title={title} description={description} />
          <div
            ref={watch}
            role="group"
            aria-roledescription="carousel"
            aria-label={t.cards}
            tabIndex={0}
            onKeyDown={keysFor(n, cur, go)}
            onPointerEnter={() => setHeld(true)}
            onPointerLeave={() => setHeld(false)}
            onFocus={() => setHeld(true)}
            onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setHeld(false)}
            className={cn('relative mx-auto mt-10 rounded-2xl py-10 [perspective:1500px] sm:mt-14', FOCUS)}
          >
            <div className="relative mx-auto w-[min(62vw,17rem)] [--cw:min(62vw,17rem)] [transform-style:preserve-3d] sm:w-[18rem] sm:[--cw:18rem]">
              <ul
                className="relative grid [transform-style:preserve-3d] motion-safe:transition-transform motion-safe:duration-[1100ms]"
                style={{
                  transform: `translateZ(calc(var(--cw) * -0.5 / tan(${round(180 / n, 3)}deg) - 40px)) rotateY(${round(-turns.current.total * step, 3)}deg)`,
                  transitionTimingFunction: ease3d,
                }}
              >
                {items.map((it, i) => {
                  const on = i === cur;
                  const d = Math.min((i - cur + n) % n, (cur - i + n) % n);
                  const Tag = it.href ? 'a' : 'div';
                  return (
                    <li
                      key={i}
                      aria-hidden={!on}
                      inert={!on ? true : undefined}
                      className="[grid-area:1/1] [transform-style:preserve-3d]"
                      style={{ transform: `rotateY(${round(i * step, 3)}deg) translateZ(calc(var(--cw) * 0.5 / tan(${round(180 / n, 3)}deg) + 40px))` }}
                    >
                      <Tag {...(it.href ? { href: it.href } : {})} className={cn('block overflow-hidden', CARD, it.href && 'cursor-pointer', FOCUS, 'transition-opacity duration-700')} style={{ opacity: on ? 1 : d === 1 ? 0.7 : 0.35 }}>
                        <div className="aspect-[4/5] overflow-hidden bg-surface [&>*]:size-full">{pic(i)}</div>
                        <div className="p-4">
                          {it.meta && <p className="truncate text-[12.5px] text-muted-foreground">{it.meta}</p>}
                          <p className="truncate text-[15px] font-semibold text-foreground">{it.title}</p>
                        </div>
                      </Tag>
                    </li>
                  );
                })}
              </ul>
            </div>
          </div>
          <div className="mt-4 flex items-center justify-center gap-3">
            <NavButton dir="prev" onClick={() => go((c) => c - 1)} label={t.prev} />
            {autoPlay && (
              <button type="button" onClick={() => setPaused((v) => !v)} aria-label={paused ? t.play : t.pause} className={cn('flex size-11 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', FOCUS)}>
                <Icon name={paused ? 'play' : 'pause'} size={16} />
              </button>
            )}
            <NavButton dir="next" onClick={() => go((c) => c + 1)} label={t.next} />
          </div>
        </div>
        {status}
      </section>
    );
  }

  /* ------------------------------------------------------------------ drum */
  const step = 28;
  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-20 md:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] md:gap-12 lg:gap-20">
        <div>
          <Head eyebrow={eyebrow} title={title} description={description} center={false} />
          <ol className="mt-8 flex flex-wrap gap-2" aria-label={t.cards}>
            {items.map((it, i) => (
              <li key={i}>
                <button
                  type="button"
                  onClick={() => go(i)}
                  aria-current={i === cur ? 'step' : undefined}
                  className={cn('flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-lg border px-3 text-[14px] font-medium tabular-nums transition-colors', FOCUS, i === cur ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-muted-foreground hover:text-foreground')}
                >
                  <span className="sr-only">{it.title}, </span>
                  {String(i + 1).padStart(2, '0')}
                </button>
              </li>
            ))}
          </ol>
        </div>
        <div className="flex items-center gap-4 sm:gap-6">
          <div role="group" aria-roledescription="carousel" aria-label={t.cards} tabIndex={0} onKeyDown={keysFor(n, cur, go, true)} className={cn('relative min-w-0 flex-1 rounded-2xl py-28 [perspective:1400px]', FOCUS)}>
            <div className="relative grid [transform-style:preserve-3d]">
              {items.map((it, i) => {
                let d = i - cur;
                if (d > n / 2) d -= n;
                if (d < -n / 2) d += n;
                const on = d === 0;
                return (
                  <article
                    key={i}
                    aria-hidden={!on}
                    inert={!on ? true : undefined}
                    className={cn('[grid-area:1/1] flex min-h-[14rem] flex-col', CARD, 'p-7 sm:p-8', MOVE, '[backface-visibility:hidden]')}
                    style={{
                      transformOrigin: '50% 50% -26rem',
                      transform: `rotateX(${-d * step}deg)`,
                      opacity: Math.abs(d) > 1 ? 0 : 1 - Math.abs(d) * 0.55,
                      zIndex: 20 - Math.abs(d),
                      transitionTimingFunction: ease3d,
                    }}
                  >
                    <div className="flex items-center justify-between gap-4">
                      <span className="text-[13px] font-semibold tabular-nums text-primary">{String(i + 1).padStart(2, '0')}</span>
                      {it.meta && <span className="text-[13px] text-muted-foreground">{it.meta}</span>}
                    </div>
                    <h3 className="mt-6 text-h3 font-semibold text-foreground">{it.title}</h3>
                    {it.text && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{it.text}</p>}
                  </article>
                );
              })}
            </div>
          </div>
          <div className="flex flex-col gap-3">
            <NavButton dir="up" onClick={() => go((c) => c - 1)} label={t.prev} />
            <NavButton dir="down" onClick={() => go((c) => c + 1)} label={t.next} />
          </div>
        </div>
      </div>
      {status}
    </section>
  );
}

Häufige Fragen

Wie installiere ich Kartenstapel in 3D?

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

Welche Varianten hat Kartenstapel in 3D?

5 Varianten, Fächer in der Hand, Stapel mit Zitaten, Kaskade aus Fenstern, Ring-Karussell und Trommel zum Durchblättern. Du wählst sie mit der Prop variant, zum Beispiel variant="deck".

Kann ich die Texte von Kartenstapel in 3D übersetzen?

Ja. Kartenstapel in 3D bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Vorige Karte“, „Nächste Karte“ und „von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kartenstapel in 3D einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert