updated
A card stack that builds itself as you scroll: each card sticks to the top of the viewport, shrinks back as the next one arrives over it, and is discarded off the top once four are stacked up, so the pile never grows past four. It is built with scroll-driven CSS animations and about fifteen lines of JavaScript. Scroll the page to see it.
⚠️Heads up! This demo uses a feature that is not supported by your browser. We will show you a video recording instead.
















The thing that took me longest to see is that a stacked cards effect is two separate techniques doing two separate jobs, and almost every explanation of it runs them together.
The stacking is not an animation at all. Every card is position: sticky with top: 0, so as you scroll, each one stops at the top of the viewport and the next card slides up underneath it. That alone gets you a pile of cards, in plain CSS, with no timeline involved and no JavaScript. If all you want is a stack, you can stop reading here.
The scroll-driven animation does the other half: it takes the cards that are already stuck and scales them back so the pile reads as depth, then discards the ones at the bottom once the stack is four deep. That is what stops it turning into a heap of forty cards by the end of the page.
A relatively new feature in modern browsers, scroll-driven animations allows you to animate based on scroll progression instead of time. While the basics are pretty simple to master, making the animation rolling is a bit more complex. You’ll need to stack multiple animations and calculate offsets based on the total wrapper height.
The timeline here is a view timeline rather than a scroll timeline: the wrapper declares view-timeline-name: --cards-scrolling, so progress is measured by how far the wrapper has traveled through the viewport rather than by how far the page has scrolled. Each card then attaches to that timeline and runs over a slice of it.
Working out the slice is where it gets gnarly. Each card’s range is expressed against the exit-crossing phase, and its start and end are computed from the card’s own index, the number of cards allowed in the stack, the card height, and the wrapper’s block size. It is a lot of arithmetic in a custom property, and it is the reason this looks harder than it is: the effect is simple, the bookkeeping is not.
To stack multiple animations, we need to create a wrapper element that will be animated for each animation we want to stack. We also need to take care to disable animations for the last few cards, so that they don’t keep shrinking after we scroll past the limit, leaving 4 stacked cards as the final state, as intended.
While the most of the effect happens in pure CSS, we’re still relying on javascript to properly shift the cards as the previous one is discarded. While this might be doable without javascript, the css calculations are already a bit gnarly, and I didn’t want to complicate things further.
Scaling a card down on its own center makes it recede, which is fine but flat. The rolling comes from one line: transform-origin: center 200%, which puts the origin well below the card, so the same scale reads as the card tipping away from you over an axis somewhere near your knees. It is a small change and it is most of the character of the effect.
The discard keyframe then takes the card out with a negative margin-top as well as opacity and scale, so the cards underneath close the gap as it leaves rather than waiting for it to finish fading.
Stripped down to the parts that make the effect, and complete enough to paste into a page once you have added a few more cards, the html explains how. The html nests each card in its scale layers, the css holds the settings, the sticky cards, the view timeline and the two animations, and the javascript shifts the stack up as cards are discarded. The full scss and tsx are on github.
<div class="wrapper">
<div class="card" style="--index0: 0">
<div class="layer" style="--depth0: 0">
<div class="layer" style="--depth0: 1">
<div class="layer" style="--depth0: 2">
<div class="content">Card 1</div>
</div>
</div>
</div>
</div>
<div class="card" style="--index0: 1">
<div class="layer" style="--depth0: 0">
<div class="layer" style="--depth0: 1">
<div class="layer" style="--depth0: 2">
<div class="content">Card 2</div>
</div>
</div>
</div>
</div>
<!--
To add a card, copy the one above and increase --index0 by one, then
set --cards-amount in the css to the number of cards.
Each card has --rolling-count - 1 layers, one per card that can land on
it, and each layer scales it back one more step. The last cards have
fewer cards after them, so they get fewer layers: drop the innermost
layer from the third to last card, two from the second to last, and all
three from the last one. Extra layers would keep shrinking them once
you scroll past.
-->
</div>Scroll-driven animations are still not everywhere, so the demo above is behind @supports (animation-timeline: view()). Where it is not supported you get a video of the effect and a note saying so, rather than a stack of cards sitting there doing nothing, which is the failure mode you get if you ship this and assume.
This is my first time playing with scroll-driven animations, as these are not yet widely supported, I wanted to get a better understanding. My conclusion is that while simple effects can be achieved with fairly simple css, more complex effects require a bit more work. You’ll need to compute animation ranges, which can get pretty intense depending on the effect you’re aiming for, and deal with browsers quirks, because life as a web engineer wouldn’t be fun without them.