Zum Inhalt springen
UI Kit
Accent

Kartenfächer

Karten, die sich auffächern, verteilen und stapeln. Drei öffnen sich beim Hereinscrollen aus einem Stapel, zwei spielt man von Hand, einen Bogen zum Drehen und ein Deck zum Austeilen. Ohne JavaScript liegt jede Karte an ihrem Platz.

Eine von 3 Components in Scroll-Reveals und Übergänge

5 Variants

Variants

Kartenhand

Ein Stapel öffnet sich beim Hereinscrollen zu einer Kartenhand. Die Karte unter dem Zeiger hebt sich heraus, auf dem Telefon per Antippen.

Im Studio öffnen

Stapel verteilt sich

Die Karten liegen anfangs als ein Stapel in der Mitte und verteilen sich beim Hereinscrollen nacheinander in ihr Raster.

Im Studio öffnen

Bogen zum Drehen

Karten auf einem weiten Bogen, die vordere steht aufrecht. Gedreht wird mit den Pfeilen, den Pfeiltasten, per Wischen oder Klick auf eine Karte.

Im Studio öffnen

Deck aus Stimmen

Ein Deck aus Zitaten neben der Überschrift. Die oberste Karte wandert per Knopf, Pfeiltaste oder Wischen nach hinten, die nächste steigt auf.

Im Studio öffnen

Stapel aus Abzügen

Ein loser Stapel Abzüge sortiert sich beim Hereinscrollen zu einer Reihe mit Bildunterschriften. Jeder Abzug behält eine leichte Drehung wie Papier auf dem Tisch.

Im Studio öffnen

Details

Was Kartenfächer mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/card-fan.tsx
Bringt mit
utilskitbuttoniconrevealsectionscroll-scene
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 5 eigene Texte

Props

Die Schnittstelle CardFanProps aus components/ui/card-fan.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
    CardFanVariant

    handspreadarcdeckphotos

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsPflicht
    FanCard[]
  • actionoptional
    { label: string; href: string }

Eingebaute Texte

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

Alle 5 Texte anzeigen
previousEnglisch Previous cardDeutsch Vorherige Karte
nextEnglisch Next cardDeutsch Nächste Karte
dealEnglisch Next quoteDeutsch Nächstes Zitat
ofEnglisch ofDeutsch von
cardsEnglisch CardsDeutsch Karten

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kartenfächer reagiert selbst auf die Tasten Pfeil links und Pfeil rechts.
  • Setzt aria-label, aria-live und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle group.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • 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).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 394 Zeilen
components/ui/card-fan.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';

/*
 * Cards that fan out, spread and pile up. Three variants open while the section scrolls in (from
 * a stack to their place), two are played by hand (an arc you turn, a deck you deal from). The
 * finished layout is the normal layout of the page; the stack is only a transform on top of it,
 * so without JavaScript and with reduced motion the cards simply lie in their place.
 *
 *   hand    a stack opens into a hand of cards, the hovered card lifts out of it
 *   spread  a stack in the middle deals itself into a grid
 *   arc     cards on a wide arc, turned with arrows, keys or a swipe
 *   deck    a deck of quotes, the top card is dealt to the back with a button or a swipe
 *   photos  a loose pile of prints sorts itself into a row with captions
 */

export type CardFanVariant = 'hand' | 'spread' | 'arc' | 'deck' | 'photos';

export interface FanCard {
  title: string;
  text?: string;
  /** Small line above or below the title: a role, a price, a place */
  eyebrow?: string;
  media?: React.ReactNode;
  href?: string;
}

export interface CardFanProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: CardFanVariant;
  eyebrow?: string;
  title?: React.ReactNode;
  description?: string;
  items: FanCard[];
  action?: { label: string; href: string };
  labels?: Partial<(typeof TEXTS)['en']>;
}

const TEXTS = {
  en: { previous: 'Previous card', next: 'Next card', deal: 'Next quote', of: 'of', cards: 'Cards' },
  de: { previous: 'Vorherige Karte', next: 'Nächste Karte', deal: 'Nächstes Zitat', of: 'von', cards: 'Karten' },
};

/** Fan geometry for the hand: angle and lift of card i of n */
function fanOf(i: number, n: number, spread = 7) {
  const mid = (n - 1) / 2;
  const d = i - mid;
  return { rot: d * spread, y: Math.abs(d) * Math.abs(d) * 6 };
}

/** A small seeded turn for the loose pile, the same on server and browser */
const TURN = [-7, 5, -3, 8, -5, 3, -8, 6];

function Head({ eyebrow, title, description, action, center = true }: Pick<CardFanProps, 'eyebrow' | 'title' | 'description' | 'action'> & { center?: boolean }) {
  if (!title && !eyebrow) return null;
  return (
    <Reveal className={cn('mb-12 max-w-[42rem] sm:mb-16', center && 'mx-auto text-center')}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      {title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
      {description && <p className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
      {action && (
        <div className={cn('mt-7 flex', center && 'justify-center')}>
          <Button href={action.href} variant="secondary" icon="arrow-right">
            {action.label}
          </Button>
        </div>
      )}
    </Reveal>
  );
}

/** Card faces */
function Portrait({ it }: { it: FanCard }) {
  return (
    <article className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
      <div className="relative min-h-0 flex-1 overflow-hidden bg-surface [&>*]:size-full">{it.media}</div>
      <div className="p-4">
        <h3 className="truncate text-[15px] font-semibold text-foreground">
          {it.href ? (
            <a href={it.href} className="after:absolute after:inset-0 focus-visible:outline-none [&:focus-visible]:after:rounded-2xl [&:focus-visible]:after:outline-2 [&:focus-visible]:after:outline-solid [&:focus-visible]:after:-outline-offset-2 [&:focus-visible]:after:outline-primary">
              {it.title}
            </a>
          ) : (
            it.title
          )}
        </h3>
        {it.eyebrow && <p className="truncate text-[13px] text-muted-foreground">{it.eyebrow}</p>}
      </div>
    </article>
  );
}

function Tile({ it }: { it: FanCard }) {
  return (
    <article className="group relative h-full overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] transition-[border-color] duration-300 hover:border-border-strong">
      <div className="relative aspect-[4/3] overflow-hidden bg-surface [&>*]:size-full [&>*]:transition-transform [&>*]:duration-700 group-hover:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{it.media}</div>
      <div className="p-5">
        {it.eyebrow && <p className="text-[12.5px] font-medium text-muted-foreground">{it.eyebrow}</p>}
        <h3 className="mt-1 text-[16px] font-semibold text-foreground">
          {it.href ? (
            <a href={it.href} className="after:absolute after:inset-0 focus-visible:outline-none [&:focus-visible]:after:rounded-2xl [&:focus-visible]:after:outline-2 [&:focus-visible]:after:outline-solid [&:focus-visible]:after:-outline-offset-2 [&:focus-visible]:after:outline-primary">
              {it.title}
            </a>
          ) : (
            it.title
          )}
        </h3>
        {it.text && <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{it.text}</p>}
      </div>
    </article>
  );
}

function QuoteCard({ it, i, n, t }: { it: FanCard; i: number; n: number; t: (typeof TEXTS)['en'] }) {
  return (
    <figure className="flex h-full flex-col justify-between gap-8 rounded-2xl border border-border bg-card p-7 shadow-[var(--highlight),var(--shadow-lg)] sm:p-9">
      <div>
        <Icon name="quote" size={24} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]" />
        <blockquote className="mt-5 text-pretty text-[clamp(1.125rem,0.95rem+0.7vw,1.5rem)] font-medium leading-snug tracking-[-0.01em] text-foreground">{it.title}</blockquote>
      </div>
      <figcaption className="flex items-center gap-3">
        {it.media && <span className="size-11 shrink-0 overflow-hidden rounded-full [&>*]:size-full">{it.media}</span>}
        <span className="min-w-0 flex-1">
          <span className="block truncate text-[14px] font-semibold text-foreground">{it.eyebrow}</span>
          {it.text && <span className="block truncate text-[13px] text-muted-foreground">{it.text}</span>}
        </span>
        <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">
          {i + 1} {t.of} {n}
        </span>
      </figcaption>
    </figure>
  );
}

function Print({ it }: { it: FanCard }) {
  return (
    <figure className="rounded-[6px] bg-[color-mix(in_oklab,var(--card)_70%,var(--foreground)_6%)] p-2.5 pb-4 shadow-[var(--shadow-lg)] ring-1 ring-border">
      <div className="aspect-square overflow-hidden rounded-[3px] bg-surface [&>*]:size-full">{it.media}</div>
      <figcaption className="mt-3 px-1">
        {/* Two lines kept free for every caption, so the prints stay one size and nothing is cut */}
        <span className="line-clamp-2 min-h-[2lh] text-[14px] font-medium leading-snug text-foreground">{it.title}</span>
        {it.eyebrow && <span className="block truncate text-[12.5px] text-muted-foreground">{it.eyebrow}</span>}
      </figcaption>
    </figure>
  );
}

/* ------------------------------------------------------------------ */
/* Scroll variants: hand, spread, photos                              */
/* ------------------------------------------------------------------ */

function useStack(live: boolean, variant: 'hand' | 'spread' | 'photos', n: number) {
  const box = useRef<HTMLDivElement>(null);
  const offsets = useRef<{ x: number; y: number }[]>([]);
  const last = useRef(1);
  const apply = useCallback(
    (p: number) => {
      last.current = p;
      const el = box.current;
      if (!el) return;
      const cards = Array.from(el.querySelectorAll<HTMLElement>(':scope [data-card]'));
      const k = smooth(span(p, 0.15, 0.95));
      // One column on phones: a card is as wide as the column, a smaller turn keeps its corners inside
      const tilt = el.clientWidth < 600 ? 0 : 1;
      cards.forEach((card, i) => {
        const o = offsets.current[i] ?? { x: 0, y: 0 };
        // Each card leaves the stack a little after the one before
        const ki = smooth(span(k, (i / Math.max(1, n)) * 0.35, 0.65 + (i / Math.max(1, n)) * 0.35));
        if (variant === 'hand') {
          const f = fanOf(i, n);
          card.style.transform = `translate3d(0, ${round(f.y * ki + (1 - ki) * 20, 1)}px, 0) rotate(${round(f.rot * ki + (i - (n - 1) / 2) * 1.2 * (1 - ki), 2)}deg)`;
        } else {
          const turn = (variant === 'photos' ? TURN[i % TURN.length] : i % 2 ? 3 : -3) * (variant === 'spread' ? tilt : 1);
          card.style.transform = `translate3d(${round(o.x * (1 - ki), 1)}px, ${round(o.y * (1 - ki), 1)}px, 0) rotate(${round(turn * (1 - ki) + (variant === 'photos' ? TURN[i % TURN.length] * 0.25 * ki : 0), 2)}deg)`;
        }
      });
    },
    [variant, n],
  );
  // Where each card sits relative to the middle of the group: the stack starts there
  useEffect(() => {
    const el = box.current;
    if (!el || !live) return;
    const measure = () => {
      const r = el.getBoundingClientRect();
      const cards = Array.from(el.querySelectorAll<HTMLElement>(':scope [data-card]'));
      // The stack sits in the middle of the first row, so it never covers the heading above
      const cx = r.left + r.width / 2;
      const cy = r.top + (cards[0] ? cards[0].offsetTop + cards[0].offsetHeight / 2 : r.height / 2);
      offsets.current = cards.map((c) => {
        // offsetLeft and offsetTop ignore the transform of the card itself
        const x = c.offsetLeft + c.offsetWidth / 2 + r.left;
        const y = c.offsetTop + c.offsetHeight / 2 + r.top;
        return { x: round(cx - x, 1), y: round(cy - y, 1) };
      });
      apply(last.current);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [live, apply]);
  useEffect(() => {
    if (live) return;
    box.current?.querySelectorAll<HTMLElement>('[data-card]').forEach((c) => c.style.removeProperty('transform'));
  }, [live]);
  return { box, apply };
}

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

/** Cards that fan, spread, turn on an arc, deal from a deck or sort from a pile. */
export function CardFan({ variant = 'hand', eyebrow, title, description, items, action, labels, className, ...rest }: CardFanProps) {
  const t = useTexts(TEXTS, labels);
  const live = useLive();
  const n = items.length;
  const scrolls = variant === 'hand' || variant === 'spread' || variant === 'photos';
  const { box, apply } = useStack(live && scrolls, variant === 'arc' || variant === 'deck' ? 'spread' : variant, n);
  const scene = useSceneProgress<HTMLDivElement>(live && scrolls, { mode: 'enter', self: false, onProgress: apply });

  /* arc and deck: played by hand */
  // The arc starts with the middle card in front, the deck with the first
  const [at, setAt] = useState(variant === 'arc' ? Math.floor((n - 1) / 2) : 0);
  const go = (d: number) => setAt((a) => (variant === 'deck' ? (a + d + n) % n : Math.min(n - 1, Math.max(0, a + d))));
  const swipe = useRef<number | null>(null);
  const onDown = (e: React.PointerEvent) => {
    swipe.current = e.clientX;
  };
  const onUp = (e: React.PointerEvent) => {
    if (swipe.current === null) return;
    const dx = e.clientX - swipe.current;
    swipe.current = null;
    if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
  };
  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowRight') {
      e.preventDefault();
      go(1);
    }
    if (e.key === 'ArrowLeft') {
      e.preventDefault();
      go(-1);
    }
  };

  const center = variant !== 'deck';
  // hand: a tap lifts a card on touch screens, where there is no hover
  const [lift, setLift] = useState<number | null>(null);

  return (
    <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
      <div className="kit-container">
        {variant !== 'deck' && <Head eyebrow={eyebrow} title={title} description={description} action={action} center={center} />}

        {variant === 'hand' && (
          <div ref={scene}>
            <div ref={box} className="relative mx-auto flex h-[22rem] max-w-[60rem] origin-top items-start justify-center max-sm:-mb-10 max-sm:scale-[0.84] sm:h-[26rem]">
              {items.map((it, i) => {
                const f = fanOf(i, n);
                const mid = (n - 1) / 2;
                return (
                  <div
                    key={i}
                    data-card
                    className={cn('group/card absolute top-0 -ml-[5.75rem] h-[17rem] w-[11.5rem] origin-[50%_120%] will-change-transform [--gap:2.7rem] sm:-ml-[6.75rem] sm:h-[20rem] sm:w-[13.5rem] sm:[--gap:4.2rem]', Math.abs(i - mid) > 2 && 'max-sm:hidden')}
                    style={{ left: `calc(50% + var(--gap) * ${round(i - mid, 2)})`, transform: `translate3d(0, ${f.y}px, 0) rotate(${f.rot}deg)`, zIndex: i }}
                    onPointerUp={(e) => {
                      if (e.pointerType !== 'mouse') setLift((l) => (l === i ? null : i));
                    }}
                  >
                    {/* The face lifts on hover and focus, the fan itself stays where the scroll put it */}
                    <div className={cn('h-full transition-transform duration-500 ease-[var(--ease-kit)] group-hover/card:-translate-y-6 group-has-[:focus-visible]/card:-translate-y-6 motion-reduce:transition-none', lift === i && '-translate-y-6')}>
                      <Portrait it={it} />
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        )}

        {variant === 'spread' && (
          <div ref={scene}>
            <div ref={box} className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
              {items.map((it, i) => (
                <div key={i} data-card className="relative will-change-transform" style={{ zIndex: n - i }}>
                  <Tile it={it} />
                </div>
              ))}
            </div>
          </div>
        )}

        {variant === 'photos' && (
          <div ref={scene}>
            <div ref={box} className={cn('grid grid-cols-2 gap-4 sm:gap-6 md:grid-cols-3', n % 5 === 0 ? 'lg:grid-cols-5' : n % 4 === 0 ? 'lg:grid-cols-4' : 'lg:grid-cols-6')}>
              {items.map((it, i) => (
                <div key={i} data-card className="will-change-transform" style={{ transform: `rotate(${round(TURN[i % TURN.length] * 0.25, 2)}deg)`, zIndex: i }}>
                  <Print it={it} />
                </div>
              ))}
            </div>
          </div>
        )}

        {variant === 'arc' && (
          <div>
            <div
              role="group"
              aria-roledescription="carousel"
              aria-label={t.cards}
              data-clip-ok
              tabIndex={0}
              onKeyDown={onKey}
              onPointerDown={onDown}
              onPointerUp={onUp}
              className="relative mx-auto h-[21rem] touch-pan-y select-none overflow-hidden rounded-2xl focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-primary sm:h-[28rem]"
            >
              {items.map((it, i) => {
                const d = i - at;
                const hidden = Math.abs(d) > 3;
                return (
                  <div
                    key={i}
                    aria-hidden={d !== 0}
                    className="absolute left-1/2 top-6 w-[13rem] origin-[50%_260%] transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none sm:w-[15rem]"
                    style={{ transform: `translateX(-50%) rotate(${d * 14}deg)`, opacity: hidden ? 0 : 1 - Math.abs(d) * 0.18, zIndex: 10 - Math.abs(d) }}
                  >
                    {d === 0 ? (
                      <Tile it={it} />
                    ) : (
                      <button type="button" tabIndex={-1} onClick={() => setAt(i)} className="block w-full text-left">
                        <Tile it={{ ...it, href: undefined }} />
                      </button>
                    )}
                  </div>
                );
              })}
            </div>
            <div className="mt-6 flex items-center justify-center gap-4">
              <button type="button" onClick={() => go(-1)} disabled={at === 0} aria-label={t.previous} className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-40">
                <Icon name="arrow-left" size={18} />
              </button>
              <span className="min-w-[4.5rem] text-center text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
                {at + 1} {t.of} {n}
              </span>
              <button type="button" onClick={() => go(1)} disabled={at === n - 1} aria-label={t.next} className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-40">
                <Icon name="arrow-right" size={18} />
              </button>
            </div>
          </div>
        )}

        {variant === 'deck' && (
          <div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-20">
            <Head eyebrow={eyebrow} title={title} description={description} action={action} center={false} />
            <div>
              <div tabIndex={0} onKeyDown={onKey} onPointerDown={onDown} onPointerUp={onUp} aria-roledescription="carousel" aria-label={t.cards} className="relative mx-auto h-[22rem] max-w-[34rem] touch-pan-y select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-8 focus-visible:outline-primary sm:h-[20rem]">
                {items.map((it, i) => {
                  // place 0 is on top, the others lie below in order, the dealt card goes to the back
                  const place = (i - at + n) % n;
                  return (
                    <div
                      key={i}
                      aria-hidden={place !== 0}
                      className="absolute inset-0 transition-[transform,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none"
                      style={{ transform: `translate3d(0, ${place * 14}px, 0) scale(${round(1 - place * 0.05, 3)}) rotate(${place === 0 ? 0 : (i % 2 ? 1.5 : -1.5)}deg)`, opacity: place > 3 ? 0 : 1, zIndex: n - place }}
                    >
                      <QuoteCard it={it} i={i} n={n} t={t} />
                    </div>
                  );
                })}
              </div>
              <div className="mt-12 flex justify-center gap-3">
                <button type="button" onClick={() => go(-1)} aria-label={t.previous} className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-primary">
                  <Icon name="arrow-left" size={18} />
                </button>
                <Button type="button" onClick={() => go(1)} variant="secondary" icon="arrow-right">
                  {t.deal}
                </Button>
              </div>
            </div>
          </div>
        )}
      </div>
    </section>
  );
}

Häufige Fragen

Wie installiere ich Kartenfächer?

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

Welche Varianten hat Kartenfächer?

5 Varianten, Kartenhand, Stapel verteilt sich, Bogen zum Drehen, Deck aus Stimmen und Stapel aus Abzügen. Du wählst sie mit der Prop variant, zum Beispiel variant="spread".

Kann ich die Texte von Kartenfächer übersetzen?

Ja. Kartenfächer bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Vorherige Karte“, „Nächste Karte“ und „Nächstes Zitat“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kartenfächer einbauen?

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

Kann ich Kartenfächer ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CardFan-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert