Quick Start
1. Add to your HTML <head>:
Code snippet omitted: it ships with the download.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Add the effect HTML anywhere in your <body>:
Code snippet omitted: it ships with the download.
Two or more [data-stack-card] panels are required; below that the script leaves the markup alone. Any number above that works, the scroll length grows with the card count and every distance is re-measured on refresh.
4. Set the counter's total to your card count. data-stack-count starts as plain text (05 / 05) so the no-JS and reduced-motion document reads correctly. The script overwrites it once it takes over.
Options
All set on the [data-stack] section:
| Attribute | Values | Default | Description |
|---|---|---|---|
data-offset |
Any number (px) | 26 |
Visible edge of each buried card per level of depth. 0 removes the peek and the deck reads as one card being replaced |
data-scale |
0 to ~0.09 |
0.055 |
Scale removed per level of depth. Past ~0.09 the third card down is small enough to read as a different component |
data-dim |
0 to 1 |
0.16 |
Opacity of black laid over each level of depth, capped at 0.88 in total |
data-max-depth |
Whole number ≥ 1 | 4 (3 on touch) |
How many levels keep moving. Deeper cards freeze at this depth, so the deck cannot grow past the top of a short viewport |
data-lean |
true, false |
true |
The velocity lean. Always off on coarse pointers regardless of this value |
data-scrub |
Any number | 1 |
Multiplier on the scroll distance each card takes to arrive. 1.5 is a slower, more deliberate deal; 0.6 is brisk |
data-stack-bar |
Any CSS selector | none | Selector for a fixed page bar. Its height is measured live, the pin starts below it and the frame shrinks to the space left over |
On coarse pointers data-offset and data-scale are scaled down automatically (to 55% and 70%) and the depth cap drops, so a five-card deck still reads as a pile at 375px without overflowing a short viewport.
How it works
One ScrollTrigger pins .stack__viewport and scrubs a single timeline that spends one unit of timeline time per arriving card. Within a unit, the arriving panel gets a fromTo from below the frame to a few pixels past its resting line (power2.out) plus a short relax onto the line, that overshoot is the lock. Simultaneously, every card already down gets its next depth values as an explicit from/to pair with a stagger, nearest card first, so the pile compresses level by level.
The from-values are explicit rather than inherited from whatever the previous step left, so invalidateOnRefresh can re-measure after a resize without the deck re-recording a mid-scroll state as its start. Card paint order is plain z-index; perspective is applied per element (transformPerspective) rather than on an ancestor, because an ancestor with perspective or transform-style: preserve-3d makes the browser paint children by computed depth and ignore z-index, which puts the card leaning furthest back in front of the whole deck.
Velocity comes from ScrollTrigger.getVelocity(), clamped and mapped to degrees, then pushed through gsap.quickTo with a back.out ease so it eases in rather than jittering and springs past level on the way back to zero. A gsap.ticker watchdog releases the lean once scroll updates stop, which is the only thing that would otherwise leave the last reading applied.
Accessibility
- Reduced motion:
prefers-reduced-motion: reduceskips the pin entirely. The panels stay the plain, fully visible vertical column the markup already is, and the counter shows the final value. Nothing is animated, pinned or hidden. - Touch devices: the mechanic is scroll-driven, so it works as-is. Depth offsets shrink, the visible depth is capped and the velocity lean is dropped, touch inertia makes the velocity reading noisy enough that the lurch reads as a bug rather than a flourish.
- No JavaScript: the base stylesheet is the readable column. The only thing gated behind the script's
.stack--liveclass is the pinned layout, and the velocity half of the cue stays hidden because it is not true without the script. - Screen readers: cards are
<article>elements in reading order with real headings; the progress rail and dim overlays arearia-hidden.
Dependencies
Required:
- GSAP 3.12+
- ScrollTrigger plugin
Optional:
- Lenis (smooth scroll). The scrubbed deck reads noticeably smoother with it; the script wires
lenis.on('scroll', ScrollTrigger.update)automatically when Lenis is present and works on native scroll when it is not.
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.