uture CSS: Wishes Granted by Scroll-driven Animations

Stuck state for sticky headers? “Proper” solution for scrolling shadows? Highlighting the currently shown sections in a Table of Contents? All these things could become possible with the new scroll-driven animations spec. Today, I gather and explain some of my experiments with this new CSS feature.

ntroduction

I would be lying if I told you I completely understood the specs for scroll-driven animations. I didn’t even read them thoroughly! However, when thinking about them, I did imagine some ways we could potentially use them — and after someGo to a sidenote experiments, I did succeed.

Some of the things I would be talking about are present in multiple people’s CSS wish lists, usually right at the top. However, maybe not everyone did envision scroll-driven animations as the thing that could bring these use cases to life.

When I initially looked at this spec, from the top of my head, I could only think of various “promo” pages with elaborate scroll effects and galleries. Or, at least, most of the examples I saw in the wild did focus on these.

In this article, I would like to demonstrate some techniques beyond effects, which could, in time, find a home as a part of more generic UI components and design systems.

xperiments

First, some important disclaimers:

Side note: For example, Scroll-driven Animations article by Bramus. Jump to this sidenote’s context.

Side note: Tested in 116.0.5801.0 at the moment of publishing. Jump to this sidenote’s context.

tuck States for the Sticky Headers

That is the use case that excites me the most. The ability to detect when an element with position: sticky becomes stuck was requestedGo to a sidenote by developers for years and years. And I’m not an exception — I also wanted to have it from at least 2017. Is it possible that we no longer require a particular state and could instead use scroll-driven animations?

Let me start right away with an exampleGo to a sidenote:

Header that reduces to 0.5 when stuck

Header that reduces to 0.75 when stuck

Header that reduces to 0.25 when stuck

This example is done with pure CSS. If you ever wanted to do something like this in production, you could remember the hoops we had to jump to make this possible. Listening to the scroll, measuring things, reserving space, using intersection observers, orchestrating animations and transitions…

Talking about animations — note how we’re not just getting a binary stuck/not-stuck stateGo to a sidenote, but can define the range over which the headers would become stuck!

Side note: Which could be potentially possible in the future via state-based container queries. Jump to this sidenote’s context.

Side note: I omit some unrelated to the scroll-driven animations code — feel free to look at the source for more context. Jump to this sidenote’s context.

How does it work? InitiallyGo to a sidenote, we need to set up the CSS variables that we would be using:

.sticky {
  --height: 2em;
  --reduce-to: 0.5;
  --distance: calc(var(--height) * (1 - var(--reduce-to)));
}
  1. We set up the total height of our header. We need to know it — this is the main limitation of our method.
  2. We define the sizeGo to a sidenote we want to get after the header becomes “stuck”. We could skip this if we would like the size to be static.
  3. We define a “distance” which we calculate based on our two previous variables. Note how in the demo, we have the bottom edge of our headers not change — the “distance” variable allows us to set the “duration” of the animation later in the animation-range.

With our variables set up, we are ready to begin working on the animations. For complex cases like the above example, I prefer to use children for this: if we’d set all the children to the same height, we could use the same values for the animation-range, which we can define as a variable on the parent element, so we can reused it later:

--animation-range:
  entry 100cqh
  entry calc(100cqh + var(--distance));

A few things to note:

  1. I’m using the entry named timeline range (we can think of it as when the element appears at the bottom of the screen when we scroll down), and because we want to do our thing when it would be at the top of the screen or scrollable container, we need to adjust it.
  2. Which brings us to 100cqh — we can use the container query length units instead of the viewportGo to a sidenote units so we could then have the same animation inside the scrollable containers (given we make them containers — which is usually simple enough).
  3. We define the range by the --distance variable, making the animation go for its length.

Now that we have our range variable in place, we can apply it to the elements that need a transition, alongside any animations that we would want to have:

.sticky-text {
  /* other styles are omitted */
  animation: auto linear shrink-text both;
  animation-timeline: view();
  animation-range: var(--animation-range);
}
.sticky::before {
  /* other styles are omitted */
  animation: auto linear reveal-and-shrink-bg both;
  animation-timeline: view();
  animation-range: var(--animation-range);
}

Here we define our timelineGo to a sidenote as view (), our range to the variable, and set the animation. In our case, we have those two animations as such:

  @keyframes shrink-text {
    from {
      transform: scale(1);
    }
    to {
      transform: scale(var(--reduce-to));
    }
  }

  @keyframes reveal-and-shrink-bg {
    from {
      opacity: 0;
      transform: scaleY(1);
    }
    to {
      opacity: 1;
      transform: scaleY(var(--reduce-to));
    }
  }

Because we want the text to shrink in both dimensions (and be always visible) but the background to only shrink vertically (and gradually appear), we need to use animations on two different elements with different transforms. Note how we can use the variable for --reduce-to, and, in case we won’t need to reduce anything and would want to add the background and shadow, using the opacity and “revealing” the element that contains these seems like the most convenient way to set this up.

oined Sticky Headers

Scroll-driven animations allow us to have something similar to the “bottom scroll margin”, where we could reserve space for the following header when we want to “join” two or more together.

Look at this example:

The first header

Next-level header that stucks alongside

This is another section

With another bundled header

Here we can not only make the headers properly stuck together even while resizing but also when the section ends: we can make the first header not go over the next one’s area (which usually happens with common sticky elements) — all thanks to another animation added to the sticky header itself that moves it to the appropriate distance when it exits its area.

Here is the complete CSS that overrides the styles of the previous example
@keyframes translate-up {
  to {
    transform: translateY(
      calc(var(--distance) - var(--next-height, 0px))
    );
  }
}

.example-1-2 .sticky {
  top: var(--scroll-margin, 0px);
  --animation-range:
    entry calc(
      100cqh - var(--scroll-margin, 0px)
    )
    entry calc(
      100cqh - var(--scroll-margin, 0px) + var(--distance)
    );
  animation: auto linear translate-up;
  animation-timeline: view();
  animation-range:
    exit calc(var(--distance) - var(--next-height, 0px))
    exit 0;
}

.example-1-2 h4.sticky {
  --reduce-to: 0.5;
  --height: 3rem;
  --next-height: 1.5rem;
}

.example-1-2 h5.sticky {
  font-size: 0.75em;
  --reduce-to: 0.5;
  --height: 1.5rem;
  --scroll-margin: 1.5rem;
}

The main thing to note here is that we introduce two new variables, which need to be used on the headers to make them “know” about the previous/next ones, allowing them to adjust things properly:


There can be a lot of other cases for stuck headers and changing styles inside of them — and I find using scroll-driven animations a rather expressive way to do so. I wish we did not have to rely on the viewport or container height, but I couldn’t yet achieve this effect without these calculations. I am not entirely sure if the fault is at the specs or the implementation (or at me) — I would need to do slightly more research on this.

“Proper” Scrolling Shadows

For my second example, I want to come back to one of my older experiments — Scrolling Shadows (andGo to a sidenote its improved version by Lea Verou). More than ten years did pass since then!

Let me show you the demo first, and then I will talk about why I can call it a “proper” implementation this time.

Scroll me!

Shadows properly go over elements:

The end.

Not enough content to grant a shadow in this box.

Implementation notes:

CSS that is responsible for the shadows (unimportant bits omitted)
@supports (animation-timeline: scroll()) {
  .example-2-1 .shadow {
    position: sticky; /* [1] */
    pointer-events: none;

    --height: min(5cqw, 0.75em); /* [2] */
    height: var(--height);

    opacity: 0; /* [3] */
    animation: auto linear to-opaque both;
    animation-timeline: scroll();

    /* background omitted */
  }

  .example-2-1 .shadow--top {
    top: 0;
    margin-bottom: calc(-1 * var(--height));

    animation-range:
      contain 0px
      contain var(--height); /* [4] */
  }

  .example-2-1 .shadow--bottom {
    bottom: 0;
    margin-top: calc(-1 * var(--height));

    animation-range:
      contain calc(100% - var(--height))
      contain 100%; /* [5] */
    animation-direction: reverse; /* [6] */

    /* background omitted */
  }
}

@keyframes to-opaque {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
  1. We’re using two sticky elementsGo to a sidenote: one at the top and one at the bottom — this way, we can make them stay inside the scrollable box. We could potentially use an additional wrapper around our scrollable container and then absolutely position our shadows, but this would mean not having a relative position on our scrollable container. Also, could anchor positioning help us in the future?
  2. We define our heightGo to a sidenote as a CSS variable, so we could re-use it later for the negative margins (so the shadows don’t take up the space) and the animation-range (which is more important). As a bonus, we can use a max () value, making the shadow less awkward for narrower containers.
  3. We need to set the opacity to 0 by default and then have both stops in the keyframes — this allows us to hide the shadows when there is nothing to scroll.
  4. We define the animation-range for the top shadow by using a contain timeline range, spanning over the distance we want the transition to take over (in this case we’re using our --height variable, but we can use any other number based on our needs).
  5. For the bottom shadow, we’re using a similar animation-range — also a contain one, but where we define it by subtracting our distance from the 100%, as we want the animation to take place only in the end.
  6. We use the animation-direction: reverse; to reverse the animation for the bottom shadow — this makes the definition of the keyframes a bit more convenient.

So, why is this a “proper” solution?

I’m really happy that with scroll-driven animations, wehave this use case covered, and this feels like an “on the nose” solution: I spotted at least one other developer — Ryan Townsend — talking about this usage for them on Mastodon.

I can’t wait for this to be widely available! And, the best part — when using the @supports, we could add this as “progressive enhancement”. Though, as spoken in the disclaimers — I’d not recommend using this in prod until it becomes stable enough to land in the regular versions of the browsers.

able of Contents with Highlighted Current Sections

We often see tables of contents on various blogs, documentation sites, and design systems. One pattern in them is to mark the currently shown items as “active”, letting the reader know where they are on the page.

Usually, this is done by either listening to the scroll in JS and marking the locations of all sections or (more efficiently) by using an intersection observer.

What if we could do this only with CSS?

olution Using timeline-scope

After a few triesGo to a sidenote, I did achieve this with scroll-driven animations. Let’s look at the example:

his is the first title

Just some content filler.
his is the second title
Just some content filler.
his is the third title
Just some content filler.
his is the fourth title
Just some content filler.
his is the fifth title
Just some content filler.
his is the sixth title
Just some content filler.
his is the seventh title
Just some content filler.
his is the eighth title
Just some content filler.
his is the nineth title
Just some content filler.
his is the tenth title
Just some content filler.

With just HTML&CSS, we have achieved two things: highlighting the currently shown sections in the table of contents and synchronizing the sidebar’s scroll position with the content, making it so we always see the current section!

How is it possible?

First, the highlighting of the current items itself. Omitting the non-important styles (and things related to the scroll synchronization for now), the final CSS required for this is relatively simple:

.example-3-2 .layout {
  timeline-scope: var(--scopes);
}

.example-3-2 .toc-link:not(:hover, :focus-visible) {
  animation: current-item linear;
  animation-timeline: var(--for);
  animation-range:
    entry min(33cqh, 33%)
    exit calc(100% - min(33cqh, 33%));
}

.example-3-2 section {
  view-timeline: var(--is);
}

@keyframes current-item {
  0%, 99.9% {
    color: #FFF;
    background: #000;
  }
}

So, not a lot, huh? But maybe you can notice the place that hides some complexity — we’re using CSS variables to assign the timeline-scope, animation-timeline, and view-timeline propertiesGo to a sidenote. Where do we set them? Right in our HTML:

<div
  class="layout"
  style="
    --scopes:
      --section-1,
      --section-2,
      /* […] */
      --section-10;
  "
>
  <ul class="toc">
    <li class="toc-item">
      <a
        class="toc-link"
        style="--for: --section-1"
        href="#section-1"
      >
        The first title
      </a>
    </li>
    <!-- […] -->
  </ul>
  <section id="section-1" style="--is: --section-1">
    <h4 class="header">This is the first title</h4>
    <!-- […] -->
  </section>
  <!-- […] -->
</div>

We need to do 2 things in HTML:

  1. List all our sections in the --scopes which would go into timeline-scope — without it, we cannot make our links outside the scroller to know about the sections and how they move in their view timelines.
  2. Connect our links with the corresponding sections via --is and --for variablesGo to a sidenote.

And — that’s it! While this might seem to bump HTML a bit, in reality, this does not add a lot of logic — outside of the necessity to wrap our sections in elements for the view transitions to workGo to a sidenote, things are straightforward: the most complicated thing would be to compile the list of all sections for the timeline-scope, but given we would already have the data to iterate through for the table of contents itself, I don’t think this is too big of an issue.

After all — in the end, we get the solution free of JS!

There are still a few things I’d want to talk about in its CSS:

And, then there is another interesting CSS aspect I’d want to point out.

croll Synchronization

If you did not notice it — go back to the example and scroll its content to the bottom and up again. What happens is that our table of contents' scrollbar moves alongside our main one!

That is another part that is usually achieved only with JS. Now — only with CSS! Let’s look at it:

.example-3-2 .toc {
  scroll-snap-type: y mandatory;
}

.example-3-2 .toc-link:not(:hover, :focus-visible) {
  animation:
    current-item linear,
    var(--snap-animation, none) linear;
  animation-timeline: var(--for);
}

.example-3-2
  .toc:not(:hover, :has(:focus-visible))
    .toc-link {
      --snap-animation: snap-to-current;
}

@keyframes snap-to-current {
  to {
    scroll-snap-align: center;
  }
}

What we did here is we added a second animation to the mix — one that enables the scroll-snap-align to the selected elements, bringing them to the center of the table of contents' (scrollbox where we apply scroll-snap-type)!

Then, one more thing — using the :not() to disable the snapping when we hover over the table of contents or if we have a keyboard focusGo to a sidenote inside. That makes it so the snapping won’t interfere with our interactions inside the scrollbox.

At first, I did not think all of this would work! But here we are — with another two modern CSS features playing nicely together, unlocking yet another previously unthinkable CSS-only solution.

Just one additional disclaimer: manipulation of the scroll snapping could sometimes be too limiting — this would require more extensive accessibility testing, so be careful!

olution Based on Anchor Positioning

Initially, I did not think we could hoist the animation timelines outside of a scrollable container, so I did work around this by using anchor positioning. Given we actually can (see the previous section), this solution looks much more flawed. In case you’re still interested, you can look at it. Otherwise, feel free to skip right to the conclusions.

An outdated example and its explanation.

As an initial proof-of-concept, I did manage to achieve this with scroll-driven animations combined with anchor positioning. Let’s look at the example:

his is the first title

Just some content filler.
his is the second title
Just some content filler.
his is the third title
Just some content filler.
his is the fourth title
Just some content filler.
his is the fifth title
Just some content filler.

Overall, how it works:

  1. We have to divide the content into sections, wrapping each in an element.
  2. For each section, we establish a view () timeline.
  3. We use a --state CSS variable, with the default value of 0, and then in the middle of the animation, set it to 1. I found it a bit easier to handle the approximate moment when we want to change the state via controlling the keyframe stops rather than fiddling with the animation-range.
  4. Now, we can use a pseudo-element somewhere in the section (I’m using it from inside the headers) to position them with anchor-positioning over their corresponding list items inside the table of contents.
  5. Because we cannot style the list items themselves, but only the element positioned above them, we could want to be creative when styling them. In my example, I used backdrop-filter to my advantage — it feels that this could be a good tool when used with anchor-positioning in general.

Sadly, there is one issue with this approach: anchor positioning doesn’t work correctly with sticky positioning — when the element gets stuck, it does not get the proper scroll offset. That means we cannot use overflow: auto for the table of contents, thus making this less useful for larger pages.

Thus, I’m glad I came up with a solution that uses just scroll-driven animations — this way, things are much more straightforward and versatile!

n Conclusion

It felt like I only did scratch the surface of what is possible with the scroll-driven animations, and there are so many more things possibleGo to a sidenote!

I’m so happy with the future of what would be possible with CSS, especially when thinking about all the combinations of the new features we’re getting, like using all these animations with the anchor positioning, or recreating the very animated experience with just scroll-driven animations and view transitions. So many nice things!

Are there other curious cases you think scroll-driven animations could solve? I urge you to go and experiment!


Let me know what you think about this article on Mastodon!