Card fan
Cards that fan out, spread and pile up. Three open from a stack while the section scrolls in, two are played by hand, an arc you turn and a deck you deal from. Without JavaScript every card lies in its place.
One of 3 components in Scroll reveals and transitions
Variants
Details
What Card fan brings along, read from the registry and the source.
- Category
- Scroll reveals and transitions
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/card-fan.tsx
- npm packages
- None
- Languages
- English and German, 5 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CardFanProps interface from components/ui/card-fan.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalCardFanVarianthandspreadarcdeckphotos
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsrequiredFanCard[]actionoptional{ label: string; href: string }
Built-in texts
Card fan writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 5 texts
| Key | English | German |
|---|---|---|
| previous | English Previous card | German Vorherige Karte |
| next | English Next card | German Nächste Karte |
| deal | English Next quote | German Nächstes Zitat |
| of | English of | German von |
| cards | English Cards | German Karten |
Accessibility
Read from the source of the component.
- Card fan handles the keys Arrow left and Arrow right itself.
- Sets aria-label, aria-live and aria-roledescription, so screen readers announce purpose and state.
- Uses the role group.
- Announces changes through a live region without moving focus.
- 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).
- With reduced motion (prefers-reduced-motion) it stays still. 4 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, 394 lines
'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>
);
}Frequent questions
How do I install Card fan?
With aiw add @ai-website/card-fan. The command puts components/ui/card-fan.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section and scroll-scene. It needs no further npm packages.
Which variants does Card fan have?
5 variants, Hand of cards, Stack deals into a grid, Arc to turn, Deck of quotes and Pile of prints. You pick one with the variant prop, for example variant="spread".
Can I translate the texts of Card fan?
Yes. Card fan carries 5 texts of its own in English and German, such as “Previous card”, “Next card” and “Next quote”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Card fan?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Card fan with aiw_components and install it with aiw_add @ai-website/card-fan. Name the variant in your prompt, for example variant="hand", and the assistant writes your content straight into the props.
Can I adjust Card fan without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching CardFan code to copy.