Sticky stack
Cards that stack on top of each other while the page scrolls. The next card slides over the last one, which steps back a little. The stacking is plain CSS, so it works without JavaScript too.
One of 4 components in Scroll stories
Variants
Details
What Sticky stack brings along, read from the registry and the source.
- Category
- Scroll stories
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/sticky-stack.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The StickyStackProps interface from components/ui/sticky-stack.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalStickyStackVariantfeaturesprocesscase-studiessplitpile
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsrequiredStackItem[]actionoptional{ label: string; href: string }
Built-in texts
Sticky stack writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| step | English Step | German Schritt |
| of | English of | German von |
| read | English Read the case study | German Fallstudie lesen |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- 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).
- Moves with the scroll, its progress follows the position on the page.
- 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, 398 lines
'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>
);
}Frequent questions
How do I install Sticky stack?
With aiw add @ai-website/sticky-stack. The command puts components/ui/sticky-stack.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 Sticky stack have?
5 variants, Feature cards, Process with title strips, Case studies, Heading and quotes and Pile of notes. You pick one with the variant prop, for example variant="process".
Can I translate the texts of Sticky stack?
Yes. Sticky stack carries 3 texts of its own in English and German, such as “Step”, “of” and “Read the case study”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Sticky stack?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sticky stack with aiw_components and install it with aiw_add @ai-website/sticky-stack. Name the variant in your prompt, for example variant="features", and the assistant writes your content straight into the props.
Can I adjust Sticky stack without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching StickyStack code to copy.