Stapelkarten
Karten, die sich beim Scrollen übereinander legen. Die nächste Karte schiebt sich über die letzte, die ein wenig zurücktritt. Das Stapeln ist reines CSS und funktioniert auch ohne JavaScript.
Eine von 4 Components in Scroll-Geschichten
Variants
Details
Was Stapelkarten mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Geschichten
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/sticky-stack.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StickyStackProps aus components/ui/sticky-stack.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalStickyStackVariantfeaturesprocesscase-studiessplitpile
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsPflichtStackItem[]actionoptional{ label: string; href: string }
Eingebaute Texte
Diese Texte schreibt Stapelkarten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| read | Englisch Read the case study | Deutsch Fallstudie lesen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- 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).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- 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, 398 Zeilen
'use client';
import { useEffect, useRef } from 'react';
import { cn, 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 { useLive } from '@/components/ui/scroll-scene';
/*
* Cards that stack on top of each other while the page scrolls. Every card sticks a little lower
* than the one before, the next one slides over it, and the covered card steps back (smaller,
* dimmer). The stacking itself is CSS (position sticky), so it works without JavaScript too;
* JavaScript only adds the stepping back. With reduced motion the cards stack without it.
*
* features large feature cards, text beside a picture
* process steps that leave their title strip visible, like tabs in a folder
* case-studies full pictures with the result in large figures
* split a heading that stays on the left, quotes stacking on the right
* pile a centered pile of notes that land slightly turned, like paper on a desk
*/
export type StickyStackVariant = 'features' | 'process' | 'case-studies' | 'split' | 'pile';
export interface StackItem {
/** Small line above the title (category, client, step name) */
eyebrow?: string;
title: string;
text?: string;
/** Picture or drawing of the card */
media?: React.ReactNode;
/** A figure that carries the card, e.g. "+38%" (case-studies, process) */
figure?: string;
figureLabel?: string;
/** Short facts as small chips */
points?: string[];
/** Person under a quote (split, pile) */
author?: string;
role?: string;
avatar?: React.ReactNode;
href?: string;
}
export interface StickyStackProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: StickyStackVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
items: StackItem[];
action?: { label: string; href: string };
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { step: 'Step', of: 'of', read: 'Read the case study' },
de: { step: 'Schritt', of: 'von', read: 'Fallstudie lesen' },
};
const pad = (n: number) => String(n).padStart(2, '0');
/**
* How far the next card has slid over each card (0 to 1), measured from the cards themselves, so
* any height and any sticky offset works. The result goes straight onto the marked elements of the
* covered card, as transform and opacity: [data-back] steps back (scale by its value, keeps a
* data-turn rotation), [data-veil] darkens, [data-fade] fades. No custom property on the card,
* that would restyle the pictures in it on every frame.
*/
function useCover(live: boolean) {
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
const el = list.current;
if (!el || !live) return;
const cards = Array.from(el.querySelectorAll<HTMLElement>(':scope > li')).map((li) => ({
li,
back: li.querySelector<HTMLElement>('[data-back]'),
veil: li.querySelector<HTMLElement>('[data-veil]'),
fade: li.querySelector<HTMLElement>('[data-fade]'),
last: -1,
}));
const base = cards.map((c) => c.back?.style.transform ?? '');
// Sticky offsets and heights change only with the layout, so they are read on resize, not per frame
let stick: number[] = [];
let heights: number[] = [];
const measure = () => {
stick = cards.map((c) => parseFloat(getComputedStyle(c.li).top) || 0);
heights = cards.map((c) => c.li.offsetHeight || 1);
};
let frame = 0;
const update = () => {
frame = 0;
// All reads first, then all writes: no layout is forced between two cards
const tops = cards.map((c) => c.li.getBoundingClientRect().top);
for (let i = 0; i < cards.length - 1; i++) {
// 0 while the next card is a full card height away from its place, 1 when it sits there
const c = Math.round(Math.min(1, Math.max(0, 1 - (tops[i + 1] - stick[i + 1]) / heights[i])) * 1000) / 1000;
const card = cards[i];
if (c === card.last) continue;
card.last = c;
if (card.back) {
const turn = Number(card.back.dataset.turn) || 0;
card.back.style.transform = `${turn ? `rotate(${turn}deg) ` : ''}scale(${(1 - c * Number(card.back.dataset.back)).toFixed(4)})`;
}
if (card.veil) card.veil.style.opacity = (c * Number(card.veil.dataset.veil)).toFixed(3);
if (card.fade) card.fade.style.opacity = (1 - c * Number(card.fade.dataset.fade)).toFixed(3);
}
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
const onResize = () => {
measure();
onScroll();
};
measure();
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
cards.forEach((c, i) => {
if (c.back) c.back.style.transform = base[i];
c.veil?.style.removeProperty('opacity');
c.fade?.style.removeProperty('opacity');
});
};
}, [live]);
return list;
}
/** The covered card steps back: smaller (by `back`) and under a calm veil. Only transform and opacity. */
const backProps = (back: number) => ({ 'data-back': back, style: { transformOrigin: '50% 0%' } as React.CSSProperties });
function Veil({ strength = 0.55 }: { strength?: number }) {
return <span aria-hidden data-veil={strength} className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-background opacity-0" />;
}
function Header({ eyebrow, title, description, action, center }: Pick<StickyStackProps, 'eyebrow' | 'title' | 'description' | 'action'> & { center?: boolean }) {
if (!title && !eyebrow) return null;
return (
<Reveal className={cn('mb-12 max-w-[44rem] 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-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
{action && (
<div className={cn('mt-8', center && 'flex justify-center')}>
<Button href={action.href} variant="secondary" icon="arrow-right">
{action.label}
</Button>
</div>
)}
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* features */
/* ------------------------------------------------------------------ */
function Features({ items, live }: { items: StackItem[]; live: boolean }) {
const list = useCover(live);
return (
<ol ref={list} className="grid grid-cols-1 gap-6 sm:gap-8">
{items.map((it, i) => (
<li key={i} className="sticky" style={{ top: `calc(clamp(1rem, 6svh, 4.5rem) + ${i} * 0.75rem)`, zIndex: i + 1 }}>
<article className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] will-change-transform" {...backProps(0.05)}>
<Veil />
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<div className="flex flex-col justify-between gap-8 p-6 sm:p-8 lg:p-12">
<div>
<p className="flex items-center gap-3 text-[13px] font-medium text-muted-foreground">
<span className="tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{pad(i + 1)}</span>
<span aria-hidden className="h-px w-8 bg-border-strong" />
{it.eyebrow}
</p>
<h3 className="mt-5 text-balance text-[clamp(1.5rem,1.1rem+1.2vw,2.125rem)] font-semibold leading-[1.15] tracking-[-0.02em] text-foreground">
{/* With href the title is the link of the card */}
{it.href ? (
<a href={it.href} className="group/title inline-flex items-center gap-2 rounded-md outline-none transition-colors hover:text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-[var(--ring)]">
{it.title}
<Icon name="arrow-right" size={20} className="shrink-0 transition-transform duration-300 ease-[var(--ease-kit)] group-hover/title:translate-x-1 motion-reduce:transition-none" />
</a>
) : (
it.title
)}
</h3>
{it.text && <p className="mt-4 max-w-[30rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
</div>
{it.points && it.points.length > 0 && (
<ul className="flex flex-wrap gap-2">
{it.points.map((pt) => (
<li key={pt} className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-[12.5px] text-foreground">
<Icon name="check" size={13} className="text-primary" />
{pt}
</li>
))}
</ul>
)}
</div>
{it.media && (
<div className="relative min-h-[13rem] overflow-hidden border-t border-border bg-surface sm:min-h-[18rem] lg:min-h-[24rem] lg:border-l lg:border-t-0 [&>*]:absolute [&>*]:inset-0 [&>*]:size-full">
{it.media}
</div>
)}
</div>
</article>
</li>
))}
</ol>
);
}
/* ------------------------------------------------------------------ */
/* process */
/* ------------------------------------------------------------------ */
function Process({ items, live, t }: { items: StackItem[]; live: boolean; t: (typeof TEXTS)['en'] }) {
const list = useCover(live);
return (
<ol ref={list} className="grid grid-cols-1 gap-4">
{items.map((it, i) => (
// Every step sticks one title strip lower, so the titles of the steps before stay readable
<li key={i} className="sticky" style={{ top: `calc(clamp(1rem, 5svh, 3.5rem) + ${i} * 3.5rem)`, zIndex: i + 1 }}>
<article className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" aria-label={`${t.step} ${i + 1} ${t.of} ${items.length}`}>
<header className="flex h-14 items-center gap-4 border-b border-border px-5 sm:px-8">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-[12px] font-semibold tabular-nums text-primary-foreground">{i + 1}</span>
<h3 className="min-w-0 flex-1 truncate text-[15px] font-semibold text-foreground">{it.title}</h3>
{it.eyebrow && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{it.eyebrow}</span>}
</header>
{/* The body fades under the next step, the strip above stays sharp */}
<div className="relative grid grid-cols-1 gap-6 p-5 sm:p-8 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] md:gap-10 lg:min-h-[17rem] lg:p-10" data-fade={0.85}>
<div>
{it.text && <p className="max-w-[34rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
{it.points && it.points.length > 0 && (
<ul className="mt-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{it.points.map((pt) => (
<li key={pt} className="flex items-start gap-2.5 text-[14px] text-foreground">
<Icon name="check-circle" size={16} className="mt-0.5 shrink-0 text-primary" />
{pt}
</li>
))}
</ul>
)}
</div>
{(it.figure || it.media) && (
<div className="flex min-h-[8rem] flex-col justify-end rounded-xl border border-border bg-surface p-5 lg:p-7">
{it.media}
{it.figure && <p className="text-[clamp(2rem,1.5rem+1.6vw,3rem)] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{it.figure}</p>}
{it.figureLabel && <p className="mt-2 text-[13px] text-muted-foreground">{it.figureLabel}</p>}
</div>
)}
</div>
</article>
</li>
))}
</ol>
);
}
/* ------------------------------------------------------------------ */
/* case-studies */
/* ------------------------------------------------------------------ */
function Cases({ items, live, t }: { items: StackItem[]; live: boolean; t: (typeof TEXTS)['en'] }) {
const list = useCover(live);
return (
<ol ref={list} className="grid grid-cols-1 gap-6">
{items.map((it, i) => (
<li key={i} className="sticky" style={{ top: `calc(clamp(1rem, 6svh, 4rem) + ${i} * 0.625rem)`, zIndex: i + 1 }}>
<article className="dark group relative isolate h-[min(34rem,74svh)] overflow-hidden rounded-2xl border border-border bg-bezel text-foreground shadow-[var(--shadow-lg)] will-change-transform sm:h-[min(36rem,72svh)]" {...backProps(0.06)}>
<Veil strength={0.6} />
<div className="absolute inset-0 -z-10 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.03] motion-reduce:transition-none [&>*]:size-full">{it.media}</div>
<span aria-hidden className="absolute inset-0 -z-10 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_94%,transparent)_0%,color-mix(in_oklab,var(--background)_86%,transparent)_55%,color-mix(in_oklab,var(--background)_62%,transparent)_74%,transparent_100%)] lg:bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_92%,transparent)_0%,color-mix(in_oklab,var(--background)_55%,transparent)_45%,transparent_75%)]" />
<div className="flex h-full flex-col justify-end gap-6 p-6 sm:p-10 lg:flex-row lg:items-end lg:justify-between lg:p-12">
<div className="max-w-[34rem]">
{it.eyebrow && <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_40%,var(--foreground))]">{it.eyebrow}</p>}
<h3 className="mt-3 text-balance text-[clamp(1.5rem,1.1rem+1.3vw,2.25rem)] font-semibold leading-[1.12] tracking-[-0.02em]">{it.title}</h3>
{it.text && <p className="mt-3 max-w-[30rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{it.text}</p>}
{it.href && (
<a href={it.href} className="mt-6 inline-flex min-h-11 items-center gap-2 rounded-full text-[14px] font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-primary">
{t.read}
<Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover:translate-x-0.5" />
</a>
)}
</div>
{it.figure && (
<div className="shrink-0 border-t border-[color-mix(in_oklab,var(--foreground)_20%,transparent)] pt-5 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0">
<p className="text-[clamp(2.75rem,2rem+3vw,5rem)] font-semibold leading-none tracking-[-0.04em] tabular-nums">{it.figure}</p>
{it.figureLabel && <p className="mt-2 text-[13.5px] text-muted-foreground">{it.figureLabel}</p>}
</div>
)}
</div>
</article>
</li>
))}
</ol>
);
}
/* ------------------------------------------------------------------ */
/* split */
/* ------------------------------------------------------------------ */
function Quote({ it }: { it: StackItem }) {
return (
<figure className="flex h-full flex-col justify-between gap-8 p-6 sm:p-8">
<div>
<Icon name="quote" size={22} 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.6vw,1.5rem)] font-medium leading-snug tracking-[-0.01em] text-foreground">{it.title}</blockquote>
</div>
<figcaption className="flex items-center gap-3">
{it.avatar && <span className="size-10 shrink-0 overflow-hidden rounded-full [&>*]:size-full">{it.avatar}</span>}
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold text-foreground">{it.author}</span>
{it.role && <span className="block truncate text-[13px] text-muted-foreground">{it.role}</span>}
</span>
{it.figure && (
<span className="ml-auto shrink-0 text-right">
<span className="block text-[18px] font-semibold tabular-nums text-foreground">{it.figure}</span>
{it.figureLabel && <span className="block text-[12px] text-muted-foreground">{it.figureLabel}</span>}
</span>
)}
</figcaption>
</figure>
);
}
function Split({ items, live, head }: { items: StackItem[]; live: boolean; head: React.ReactNode }) {
const list = useCover(live);
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<div className="lg:sticky lg:top-[clamp(2rem,12svh,7rem)] lg:self-start [&>div]:mb-0">{head}</div>
<ol ref={list} className="grid grid-cols-1 gap-5">
{items.map((it, i) => (
<li key={i} className="sticky" style={{ top: `calc(clamp(1rem, 12svh, 7rem) + ${i} * 0.875rem)`, zIndex: i + 1 }}>
<article className="relative min-h-[16rem] overflow-hidden rounded-2xl lg:min-h-[20rem] border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] will-change-transform" {...backProps(0.04)}>
<Veil />
<Quote it={it} />
</article>
</li>
))}
</ol>
</div>
);
}
/* ------------------------------------------------------------------ */
/* pile */
/* ------------------------------------------------------------------ */
const TURN = [-2.2, 1.6, -1.1, 2.4, -1.8, 1.2];
function Pile({ items, live }: { items: StackItem[]; live: boolean }) {
const list = useCover(live);
return (
<ol ref={list} className="mx-auto grid max-w-[38rem] grid-cols-1 gap-10">
{items.map((it, i) => (
<li key={i} className="sticky" style={{ top: `calc(clamp(1.5rem, 16svh, 9rem) + ${i} * 0.375rem)`, zIndex: i + 1 }}>
{/* Each note lands turned a little, so the pile looks laid by hand. */}
<article
className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)] will-change-transform"
data-back={0.04}
data-turn={TURN[i % TURN.length]}
style={{ transform: `rotate(${TURN[i % TURN.length]}deg)` }}
>
<Veil strength={0.45} />
{it.media && <div className="relative h-40 overflow-hidden border-b border-border bg-surface sm:h-48 [&>*]:size-full">{it.media}</div>}
<Quote it={it} />
</article>
</li>
))}
</ol>
);
}
/* ------------------------------------------------------------------ */
/** Cards that stack while the page scrolls, five layouts. */
export function StickyStack({ variant = 'features', eyebrow, title, description, items, action, labels, className, ...rest }: StickyStackProps) {
const t = useTexts(TEXTS, labels);
const live = useLive();
const head = <Header eyebrow={eyebrow} title={title} description={description} action={action} center={variant === 'pile'} />;
return (
// overflow-x-clip, not hidden: hidden would stop the cards from sticking
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
<div className="kit-container">
{variant !== 'split' && head}
{variant === 'features' && <Features items={items} live={live} />}
{variant === 'process' && <Process items={items} live={live} t={t} />}
{variant === 'case-studies' && <Cases items={items} live={live} t={t} />}
{variant === 'split' && <Split items={items} live={live} head={head} />}
{variant === 'pile' && <Pile items={items} live={live} />}
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Stapelkarten?
Mit aiw add @ai-website/sticky-stack. Der Befehl legt die Datei components/ui/sticky-stack.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 Stapelkarten?
5 Varianten, Feature-Karten, Ablauf mit Titelleisten, Fallstudien, Überschrift und Stimmen und Stapel aus Notizen. Du wählst sie mit der Prop variant, zum Beispiel variant="process".
Kann ich die Texte von Stapelkarten übersetzen?
Ja. Stapelkarten bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Schritt“, „von“ und „Fallstudie lesen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Stapelkarten einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Stapelkarten mit aiw_components und installieren sie mit aiw_add @ai-website/sticky-stack. Nenn im Prompt die Variante, etwa variant="features", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Stapelkarten ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden StickyStack-Code zum Kopieren.