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.
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
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 7 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- 3D und Bewegung
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
variantoptionalCardStack3DVariantfandeckcascaderingdrum
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsPflichtStack3DItem[]mediaoptionalReact.ReactNode[]Pictures on the cards (fan, cascade, ring)
autoPlayoptionalbooleanring: turn on its own while on screen. Default true.
intervaloptionalnumberring: seconds per card. Default 4.
screenWidthoptionalnumbercascade: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| prev | Englisch Previous card | Deutsch Vorige Karte |
| next | Englisch Next card | Deutsch Nächste Karte |
| of | Englisch of | Deutsch von |
| pause | Englisch Pause turning | Deutsch Drehen anhalten |
| play | Englisch Start turning | Deutsch Drehen starten |
| cards | Englisch Cards | Deutsch Karten |
| open | Englisch Open | Deutsch Ö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
'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.