Skip to content
UI Kit
Accent color

3D card carousel and card stacks

Cards arranged in real depth, a fan in a hand, a deck to deal from, a cascade of windows, a turning ring and a drum to flip through. Live HTML on every card, arrow keys everywhere, the ring pauses on hover and focus.

5 variantsOne of 6 components in 3D surfaces

Cards arranged in real depth

A card stack shows several items at once while one stays in front. These five arrangements put live HTML on every card in real CSS depth, all of them work with arrow keys, and the deck of quotes names each new card in a live region.

Fan in the hand tips cards back into the room and lifts the one under the pointer or focus. Deck of quotes sends the front card to the back of the deck on next. Cascade of windows brings one app window forward from a tab list beside it. Ring carousel turns cards on a ring in space, stops on hover and focus and has a pause button. Drum to flip through turns steps upward like an index box, moved by buttons, numbers and the up and down keys.

Use the fan or the cascade to hint at variety, the deck for quotes one at a time. The ring carousel moves on its own, so keep its cards short and its pause button visible. The drum suits steps that follow each other.

Variants

Fan in the handNew in 1.2

Cards held as a fan, tipped back into the room. The card under the pointer or focus lifts out, the whole fan leans with the mouse.

Deck of quotesNew in 1.2

Quotes stacked in depth. Next sends the front card down and away, it joins the back of the deck. Arrow keys deal as well, a live region names the card.

Cascade of windowsNew in 1.2

App windows cascading into the room. A tab list beside them brings one to the front, the others step back and fade into the page.

Ring carouselNew in 1.2

Cards on a ring that turns in space, one step every few seconds while on screen. It stops on hover and focus, a pause button stops it for good.

Drum to flip throughNew in 1.2

Steps on a drum that turns upward like an index box. Buttons, the numbers and the up and down keys move it, the neighbours stay in view.

Details

What Card stacks in 3D 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/card-stack-3d.tsx
Brings along
utilskitsectioniconrevealdepthmockup
npm packages
None
Languages
English and German, 7 texts of its own
Studio
Adjust in code
In collections
3D and motion

Props

The CardStack3DProps interface from components/ui/card-stack-3d.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    CardStack3DVariant

    fandeckcascaderingdrum

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

Built-in texts

Card stacks in 3D writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
prevEnglish Previous cardGerman Vorige Karte
nextEnglish Next cardGerman Nächste Karte
ofEnglish ofGerman von
pauseEnglish Pause turningGerman Drehen anhalten
playEnglish Start turningGerman Drehen starten
cardsEnglish CardsGerman Karten
openEnglish OpenGerman Öffnen

Accessibility

Read from the source of the component.

  • Card stacks in 3D handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
  • Sets aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-orientation, aria-roledescription and aria-selected, so screen readers announce purpose and state.
  • Uses the roles group, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • 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, 479 lines
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>
  );
}

Frequent questions

How do I install Card stacks in 3D?

With aiw add @ai-website/card-stack-3d. The command puts components/ui/card-stack-3d.tsx into your project, the code is yours from then on. It brings along section, icon, reveal, depth and mockup. It needs no further npm packages.

Which variants does Card stacks in 3D have?

5 variants, Fan in the hand, Deck of quotes, Cascade of windows, Ring carousel and Drum to flip through. You pick one with the variant prop, for example variant="deck".

Can I translate the texts of Card stacks in 3D?

Yes. Card stacks in 3D carries 7 texts of its own in English and German, such as “Previous card”, “Next card” and “of”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Card stacks in 3D?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Card stacks in 3D with aiw_components and install it with aiw_add @ai-website/card-stack-3d. Name the variant in your prompt, for example variant="fan", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard