Back to gallery

Rolling stacked cards, a scroll-driven animation

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.

Preview card: Hello Mat
Preview card: A details (or disclosure) component
Preview card: A collapsible toolbar
Preview card: A macOS inspired dock
Preview card: A carousel component
Preview card: Hello Mat
Preview card: A details (or disclosure) component
Preview card: A collapsible toolbar
Preview card: A macOS inspired dock
Preview card: A carousel component
Preview card: Hello Mat
Preview card: A details (or disclosure) component
Preview card: A collapsible toolbar
Preview card: A macOS inspired dock
Preview card: A carousel component
Preview card: Hello Mat

Two mechanisms, not one

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.

Scroll driven animations

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.

Stacking multiple scroll-driven animations

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.

Javascript usage

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.

What makes it roll

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.

The code

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>

When the browser cannot do it

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.

That’s a wrap

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.

Up next

Swipeable cards carousel
-->
<--

Right before

A carousel component