tool / stacking cardssticky · scale · dim
Stacking cards on scroll
Cards that pile up as the page scrolls: each one sticks a little lower than the last, and the one underneath shrinks and dims as the next arrives. Set the number of cards, where they stick, their height, the shrink, the dim and the corners, scroll the preview, and copy it in CSS alone, as HTML, CSS and JavaScript, or as a React component.
- Settings
- 7
- Cards
- 2–6
- scrollProgress alone
- 2.9 KB
- Library
- 2.27.3
Build a stack of cards
The box below is a small window. Scroll it, drag the slider or press Play: each card sticks under the one before, a step lower, and the card underneath shrinks and dims while the next slides over it. Preview it with scrollProgress, the JavaScript tab’s call told to follow the box, or with the CSS only tab’s stylesheet and no script. The numbers beside it are read back from the cards as the browser has them.
Window · 460px tall · scroll it
- 01 / 04
Stick
Each card is position: sticky, a little lower than the one before.
- 02 / 04
Pile up
The next card scrolls up over it and stops one step further down.
- 03 / 04
Step back
The card underneath shrinks and dims while the next one arrives.
- 04 / 04
Keep going
Every card gets the same stretch of scroll, however many there are.
- Land
- height 280 + gap 24 − step 16 = 288px of scroll a card
- Card i
- sticks at 48 + i × 16px, after i × 288px; covered over the next 288px
- Pile
- 48 + 3 × 16 + 280 = 376px tall once every card is in
- Engine
- scrollProgress · p × list height = px past; cover = clamp(px ÷ 288 − i, 0, 1)
/* Made with https://svg-scroll-draw.vercel.app/stacking-cards-scroll-generator */
/* The list's top padding is the first card's offset, so the first card
sticks just as the list's top reaches the window's top. */
.stack {
margin: 0;
padding: 48px 0 0;
list-style: none;
}
/* Room for the pile to rest, one card's scroll, before it leaves. */
.stack::after { content: ''; display: block; height: 288px; }
.stack-card {
position: sticky;
top: calc(48px + var(--i) * 16px); /* each card 16px lower than the last */
box-sizing: border-box;
height: 280px;
margin-bottom: 24px;
padding: 28px;
overflow: hidden;
border-radius: 20px;
color: #fff;
box-shadow: 0 -6px 24px rgb(0 0 0 / 0.18);
transform-origin: 50% 0; /* shrinks towards its top edge, which stays in view */
}
.stack-card:nth-child(1) { background: #1f3a5f; }
.stack-card:nth-child(2) { background: #7a3b2e; }
.stack-card:nth-child(3) { background: #2f5d50; }
.stack-card:nth-child(4) { background: #5b4a7a; }
.stack-card-n { font: 600 13px/1 ui-monospace, monospace; opacity: 0.7; }
.stack-card h3 { margin: 16px 0 8px; font-size: 28px; line-height: 1.15; }
.stack-card p { max-width: 36ch; margin: 0; font-size: 16px; line-height: 1.5; opacity: 0.85; }
/* Where scroll-driven animations run, a covered card shrinks and dims.
Everywhere else this block is skipped: the cards still stack. */
@supports (animation-timeline: view()) and (view-timeline-name: --x) {
.stack { view-timeline: --stack block; }
/* exit-crossing 0 is the list's top at the window's top: card 0 sticks
there, and card i, i × 288px later (height 280 + gap 24 − step 16).
Each card's range is the 288px in which the next one lands on it. */
.stack-card {
animation-name: stack-cover;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-timeline: --stack;
animation-range: exit-crossing calc(var(--i) * 288px) exit-crossing calc(var(--i) * 288px + 288px);
}
.stack-card:last-child { animation: none; } /* nothing lands on the last one */
}
@keyframes stack-cover {
to { scale: 0.92; filter: brightness(0.7); }
}
/* The cards still stack; they only stop shrinking and dimming. */
@media (prefers-reduced-motion: reduce) {
.stack-card { animation: none; }
}With the HTML tab’s markup and no script: position: sticky stacks the cards, and a view timeline on the list scales and dims them. Without scroll timelines they still stack.
How the cards stack
Card i is position: sticky at top + i × step, inside one list. While the list scrolls past, each card scrolls up until it reaches its own top, stops there over the cards before it, and leaves its strip of them, step px, in view (fig. 1). The list has a top padding as tall as top, so the first card sticks just as the list’s top reaches the window’s top: from there, card i sticks after i × LAND px of scroll, where LAND = card height + gap − step, and the next card lands on it LAND px later. With the defaults that is 280 + 24 − 16 = 288px.
| Setting | Range | Becomes | What it does |
|---|---|---|---|
| Cards | 2–6 | one <li class="stack-card" style="--i: n"> | How many cards. --i is each card’s place: it sets the card’s offset and its stretch of scroll, so a card added to the markup with the next --i joins the stack. |
| top | 0–120px | .stack { padding-top }, the first card’s top | Where the first card sticks: under a fixed or sticky header, its height. The list is given the same top padding, so the first card sticks just as the list’s top reaches the window’s top. |
| step | 0–40px | top: calc(top + var(--i) × step) | How much lower each next card sticks: the strip of every covered card that stays in view. 0 lays every card exactly over the last. |
| card height | 200–480px | height | Each card’s height. A fixed height keeps every card’s stretch of scroll the same, which is what lets one number, LAND, place them all. |
| scale | 0–20% | scale: 1 − scale × cover | How much a covered card shrinks, towards its top edge (transform-origin: 50% 0), so its strip stays where it was. |
| dim | 0–60% | filter: brightness(1 − dim × cover) | How much a covered card darkens, which pushes it back behind the one arriving. |
| rounded corners | 0–40px | border-radius | The cards’ corners. overflow: hidden keeps their content inside them. |
clamp(past ÷ LAND − i, 0, 1), where past is the scroll since the list’s top passed the window’s top. The last card has nothing landing on it, so it never shrinks.The pure CSS version
The stacking is position: sticky alone, in every current browser. If the cards only have to pile up, this is all it takes:
/* Cards that only stack: no timeline, no script. */
.stack-card {
position: sticky;
top: calc(48px + var(--i) * 16px);
height: 280px;
margin-bottom: 24px;
}The shrink and dim need scroll-driven animations. The CSS only tab gives the list a view timeline. On it, exit-crossing 0 is the moment the list’s top reaches the window’s top, which is where the first card sticks, and a length after it is that much more scroll. So card i’s range is exit-crossing calc(var(--i) * LAND) exit-crossing calc(var(--i) * LAND + LAND): one rule for every card, whatever their number. Its keyframes only say where it ends, so before its range a card shows as written, and forwards keeps it covered after. It all sits inside @supports: where there are no scroll timelines the cards still stack.
| CSS: sticky and a view timeline | Sticky and scrollProgress | |
|---|---|---|
| The stacking | position: sticky, in every current browser. | The same position: sticky: the script does not move the cards. |
| Shrink and dim | A view timeline on the list and one animation-range per card: Chrome, Edge 115, Safari (macOS, iOS) 26, Firefox 159. Elsewhere the cards stack without it. | scrollProgress writes --cover on each card, in any browser that runs the script. |
| JavaScript | None. | 2.9 KB gzipped, the progress entry on its own. |
| Your code knows | Nothing. | How far each card is covered, every frame: enough to change its text, play a video or count which cards were seen. |
| Reduced motion | animation: none: the cards stack, and do not shrink or dim. | The same, in the CSS: scale: none and filter: none. |
| What breaks it | An ancestor with overflow hidden, auto or scroll between the list and the page, which stops position: sticky. | The same, and a list whose height changes after load is measured again by scrollProgress, so it keeps up. |
The script: scrollProgress on the list
The JavaScript tab keeps the stacking in CSS and hands the shrink and dim to scrollProgress(target, options), which follows the scroll in every browser and writes a number for CSS to read. It is called once, on the list, not once per card: a sticky card’s position on screen stops changing while it is stuck, so the list, which never sticks, is the one to measure.
| Option | Type | Default | Set to |
|---|---|---|---|
| trigger | { start, end } | 'top bottom' → 'bottom top' | 'top top' → 'bottom top': 0 when the list’s top reaches the window’s top, 1 when its bottom does, so progress × the list’s height is the scroll past its top. |
| easedVariable | string | null | '--scroll-progress-eased' | null: the cards read the raw progress only. |
| onProgress | (raw, eased) => void | — | Works out each card’s cover, 0 to 1, and writes it to --cover on the card. |
| variable | string | '--scroll-progress' | Left at its default: the raw progress is written to the list too, if your CSS wants it. |
| scrollContainer | string | Element | window | Left out. Pass it when the cards scroll inside a box of their own, as the preview does. |
destroy() takes the listeners off and removes the variable it wrote; the React tab also takes each card’s --cover off. On the server it returns an instance that does nothing.Use it in your project
scrollProgress has an entry of its own, svg-scroll-draw/progress, and the generated code imports it from there.
Stacking cards questions
How do I make cards stack on scroll with CSS?
Give every card position: sticky and a top a little lower than the last one's: top: calc(48px + var(--i) * 16px), with --i the card's place in the list. Each card then scrolls up over the one before and stops 16px below it, so the cards pile up with their top edges showing. That part needs no script and works in every current browser. To shrink and dim the covered cards as well, add a view timeline on the list and one animation-range per card, inside @supports, as the CSS only tab writes.
How do I scale down the card underneath as the next one arrives?
Work out how far the next card has come: with a fixed card height, card i + 1 lands on card i after the same scroll for every card, LAND = card height + gap − step (288px with the defaults). Card i is covered over the scroll from i × LAND to (i + 1) × LAND past the list's top. In CSS that is animation-range: exit-crossing calc(var(--i) * LAND) exit-crossing calc(var(--i) * LAND + LAND) on the list's view timeline; with scrollProgress it is clamp(past ÷ LAND − i, 0, 1), written to --cover. Scale towards the card's top edge, with transform-origin: 50% 0, so its strip stays put.
Why are my sticky cards not stacking?
Most often an ancestor between the cards and the page has overflow set to hidden, auto or scroll: the cards then stick inside that box, which does not scroll, so they never seem to stick. overflow: clip does not do this. Next, the cards must share one parent that runs the whole length of the stack, since a sticky element only holds while its parent is on screen; and each needs a top. If they stick but the later ones slide under the earlier ones, something has given the earlier cards a higher z-index.
Why does the pile leave as soon as the last card lands?
A sticky card cannot leave its parent, so the pile scrolls away when the list's bottom reaches it. With nothing after the last card, that is the moment it lands. The generated CSS adds room after the last card with .stack::after, one card's scroll (288px with the defaults), so the finished pile rests there before it goes. Make it taller to hold it longer.
Does it work on phones?
Yes: position: sticky and the scroll timeline both work in mobile browsers, and the cards are as wide as their list. What to watch is height: the pile is top + (cards − 1) × step + card height tall (376px with the defaults), and it has to fit the shortest window you support, or the last card's bottom is never seen while stuck. The generator warns when the pile is taller than its preview window.
What about readers who ask for reduced motion?
The generated CSS keeps the stacking and drops the shrink and dim for them: animation: none in the CSS only version, scale: none and filter: none in the script version. The cards still move only as the reader scrolls, which is direct manipulation rather than motion that plays at them.
Can I make the card stack without GSAP?
Yes. The stacking is position: sticky, which needs no library at all, and the shrink and dim are a scroll-driven animation in CSS, or scrollProgress from svg-scroll-draw/progress at 2.9 KB gzipped. GSAP ScrollTrigger versions of this effect often pin each card with pin: true and scrub a scale tween; here the browser does the pinning.
More on scroll with svg-scroll-draw 2.27.3: CSS scroll-driven animations, pinning without GSAP and scrollProgress in the docs.