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:
- I would not go in-depth into how scroll-driven animations work. There are other resourcesGo to a sidenote available where you could learn about them — and, as I mentioned in the beginning, I do not understand them enough to be able to distill their meaning in an understandable form.
- This article only talks about things that are not available in the current versions of the browsers — in fact, the only place my experiments work is the latest Chrome CanaryGo to a sidenote.
- This is still an experimental technology: both the specs and the implementation could change, breaking my use cases.
- As always — if you think I missed some disclaimers, let me know, and I would be happy to add them here!
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:
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)));
}
- We set up the total height of our header. We need to know it — this is the main limitation of our method.
- 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.
- 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:
- 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. - 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). - 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:
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:
--next-height
— the height of the next header that would get stuck in the same group, is used to fix the “exit” state.--scroll-margin
— should be the current accumulated heightGo to a sidenote of previously stuck headers, used to attach the second header “below” the first one.
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.
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;
}
}
- 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?
- 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 amax ()
value, making the shadow less awkward for narrower containers. - We need to set the
opacity
to0
by default and then have both stops in the keyframes — this allows us to hide the shadows when there is nothing to scroll. - We define the
animation-range
for the top shadow by using acontain
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). - For the bottom shadow, we’re using a similar
animation-range
— also acontain
one, but where we define it by subtracting our distance from the100%
, as we want the animation to take place only in the end. - 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?
- Unlike the hacks with overlaying backgrounds, this time, our shadows can properly go above any elements in the content (granted, we won’t have issues with
z-index
). - Similarly, we are free to use the shadows with any non-uniform or transparent background, as they now properly disappear instead of being overlayed by another solid background.
- Using the scroll-driven animations for this purpose just feels right, and seems simple enough. If we could describe what we want from the shadows, it would be something like “The top shadow should appear when we would start scrolling the container”, and “the bottom shadow should disappear when we would get to the end” — this is expressed quite literally with our
animations-range
s.
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?
timeline-scope
olution Using After a few triesGo to a sidenote, I did achieve this with scroll-driven animations. Let’s look at the example:
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:
- List all our sections in the
--scopes
which would go intotimeline-scope
— without it, we cannot make our links outside the scroller to know about the sections and how they move in their view timelines. - 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:
- This time I did use the
animation-range
with bothentry
andexit
parts, each calculated based on container’s size and the element’s size. We could still tweak this part for a better effect, but I found the combination I used work quite good for my examples. It gives a good result smaller and larger sections. - The keyframes are a bit weird:
0%, 99.9%
with the same value. The99.9%
is a workaround for a bug (I still need to isolate and fill it), and I found using this way of setting the styles to work quite well, as we would essentially get it applied as a state based on if any part of the section fits into its view timeline.
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:
Overall, how it works:
- We have to divide the content into sections, wrapping each in an element.
- For each section, we establish a
view ()
timeline. - We use a
--state
CSS variable, with the default value of0
, and then in the middle of the animation, set it to1
. 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 theanimation-range
. - 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.
- 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!
Published on with tags: #Scroll Driven Animations #Position Sticky #Future CSS #Experiment #CSS