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
position: sticky; top: top + i × stepwindowheadercard 1card 2card 3 ↑lands at top + 2 × steptopstepscroll past the list’s top0 × LANDcard 1 covered 0 → 11 × LANDcard 2 covered 0 → 12 × LANDcard 3 covered 0 → 13 × LANDcard 4: never coverednowLAND = card height + gap − stepscale = 1 − scale × cover · brightness = 1 − dim × cover
Fig. 1 — every card sticks one step lower than the last. The scroll between one card sticking and the next landing on it is the same for every card, so each one is covered over its own equal stretch.
01The tool

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.

Cards
top · first card sticks at48px
step · each next card lower by16px
card height280px
scale · a covered card shrinks by8%
dim · a covered card darkens by30%
rounded corners20px
Preview with

Window · 460px tall · scroll it

Content before the cardsscroll ↓
  1. 01 / 04

    Stick

    Each card is position: sticky, a little lower than the one before.

  2. 02 / 04

    Pile up

    The next card scrolls up over it and stops one step further down.

  3. 03 / 04

    Step back

    The card underneath shrinks and dims while the next one arrives.

  4. 04 / 04

    Keep going

    Every card gets the same stretch of scroll, however many there are.

The page goes on
Past the list’s top0px
On top1 / 4
CardSticks atCoveredscale · brightness
0148px1.00 · 1.00
0264px1.00 · 1.00
0380px1.00 · 1.00
0496pxlast: never1.00 · 1.00
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)
stack-css-only.css58 lines
/* 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.

Run it
Recipe
For a stage that holds still while steps change inside it, see the scroll pin generator; for any other effect on a scroll timeline, the CSS scroll-driven animation generator.
027 settings

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.

SettingRangeBecomesWhat it does
Cards2–6one <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.
top0–120px.stack { padding-top }, the first card’s topWhere 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.
step0–40pxtop: 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 height200–480pxheightEach 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.
scale0–20%scale: 1 − scale × coverHow much a covered card shrinks, towards its top edge (transform-origin: 50% 0), so its strip stays where it was.
dim0–60%filter: brightness(1 − dim × cover)How much a covered card darkens, which pushes it back behind the one arriving.
rounded corners0–40pxborder-radiusThe cards’ corners. overflow: hidden keeps their content inside them.
Covered
Each card’s cover runs 0 to 1 over its own LAND: 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 pile
Once every card is in, the pile is top + (cards − 1) × step + card height tall: 376px with the defaults. It has to fit the shortest window you support. The generated CSS adds one LAND of room after the last card, so the finished pile rests before the page takes it away.
03No script

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:

stack.css7 lines
/* 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 timelineSticky and scrollProgress
The stackingposition: sticky, in every current browser.The same position: sticky: the script does not move the cards.
Shrink and dimA 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.
JavaScriptNone.2.9 KB gzipped, the progress entry on its own.
Your code knowsNothing.How far each card is covered, every frame: enough to change its text, play a video or count which cards were seen.
Reduced motionanimation: none: the cards stack, and do not shrink or dim.The same, in the CSS: scale: none and filter: none.
What breaks itAn 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.
Tested
Both files were scrolled through in Playwright’s Chromium, Firefox and WebKit: every card stuck at top + i × step, the covered card was half way to its scale and brightness when the next one was half way in, and fully there once it landed. Under reduced motion they stacked without either, and at 375px the page did not scroll sideways.
045 options

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.

OptionTypeDefaultSet 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.
easedVariablestring | 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.
variablestring'--scroll-progress'Left at its default: the raw progress is written to the list too, if your CSS wants it.
scrollContainerstring | ElementwindowLeft out. Pass it when the cards scroll inside a box of their own, as the preview does.
Measured
scrollProgress measures the list when it starts, and again when the window resizes or the layout changes: images, fonts, late content. The cards’ height is fixed, so LAND is a constant in the script.
Instance
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.
05Next step

Use it in your project

scrollProgress has an entry of its own, svg-scroll-draw/progress, and the generated code imports it from there.

067 questions

Stacking cards questions

Q1

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.

Q2

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.

Q3

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.

Q4

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.

Q5

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.

Q6

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.

Q7

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.