Written by David Omotayo✏️
Interactive user interface features, like scroll-aware UI state, can improve website user experience but often pose challenges regarding performance optimization. This is largely because most interactive UI functionalities are implemented using JavaScript.
CSS-only solutions can reduce the workload on the rendering pipeline, as compared to JavaScript which works on the main thread. However, there are limitations to what can be achieved solely with CSS.
In this article, we'll investigate the concept of scroll-aware UI state and discuss how it can help strike a balance between the level of interactivity and system performance. We’ll also explore methods to implement this feature using only CSS.
Prerequisites
To follow along with this tutorial, you should have:
- Foundational knowledge of CSS
- Familiarity with JavaScript
What is scroll-aware state?
Scroll-aware user interface state enables developers to create native-like interactivity on web interfaces. This is a popular approach for improving website user experience.
"Scroll-aware state" may seem like an obscure term, but it's just a fancy way of saying "scroll-driven UI" — it’s a UX pattern that responds dynamically and adaptively to the user's scrolling action on a webpage.
Here’s an example that showcases how a scroll-aware UI looks and behaves:
This UX pattern is often used in combination with JavaScript APIs such as the Intersection Observer API, or third-party packages to monitor, calculate, and store the state of a user’s scroll progress and position. This position can then trigger CSS animations or styles that transform the appearance, behavior, or visibility of elements or components on a webpage.
Scroll-driven UI designs can be as simple as a scroll-snap, fixed positioning, or even a parallax effect made solely with CSS. They can also be more complex. With the recent additions to the CSS scroll-driven animation specification, it’s now possible to achieve the same results as complex animations made with JavaScript using only CSS.
Implementing scroll-aware animations with CSS
A scroll-aware UI can range from a simple scroll-snap effect to an element that gets animated when it is scrolled into view by a user or when a user scrubs forward or backward in direct response. There are several ways to implement this kind of UX pattern.
A simple scroll-snap is the most common type of scroll-aware UI. Implementing this effect is as easy as adding a scroll-snap-type: y
or position: fixed
one-liner property to a container to get a result like this:
This type of scroll-aware UI is simple to implement, but there's far more you can do beyond our basic example. For a more comprehensive understanding of how these properties work and how to use them, check out: How to style scroll snap points with CSS and Build a custom sticky navbar with CSS.
The latter example is not an easy case of slapping a CSS property to a container; it requires calculating a user's scroll distance on a webpage and detecting when an element comes into view. CSS is not capable of such functionality, so scroll-driven animations with CSS are basically an impossible feat — or were at the time that article was written.
A new set of APIs that work in conjunction with the Web Animation API (WAAPI) and the CSS Animations API were recently introduced to the CSS scroll-driven animations specification to facilitate the implementation of declarative scroll-driven animations using only CSS. These APIs are an addition to the list of animation timeline property values that provide a new and accessible way of controlling the progress of CSS animations:
- Scroll progress timeline
- View progress timeline
To understand how these timelines work, let’s first look at what an animation timeline entails.
Understanding the CSS animation-timeline
property
The CSS animation-timeline
property is used to specify the timeline that controls the progress of a CSS animation. Before the introduction of the scroll and view progress timelines, we only had access to the default document timeline.
The document timeline was brittle, lacking customization options, and it progressed continuously from the web page’s initial load. Webpage animations automatically started when the page loaded and continued for the specified animation duration. Before the availability of scroll and view progress timelines, there was no flexibility to control this behavior.
The animation-timeline
property accepts any of the following values: scroll()
, view()
, or auto
, and is assigned to an element that has an animation preset. For example, to rotate a box
element when a user scrolls up or down the webpage, you'd have an animation preset like the following before adding the animate-timeline
property and a specific value:
#box {
animation-name: box;
animation-direction: alternate;
/* animation timeline with any of the accepted values */
}
@keyframes box {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
This will cause the box to animate as expected, but it will use the specified timeline instead of the default document timeline:
Scroll progress timeline
The scroll progress timeline operates based on the scroll position of a scrollable element, which may be referred to as a scrollport, scroller, or source. Since the timeline is tied to the container of the animating element, its timing progresses only when the container's scroll position undergoes changes.
The container’s scroll position is indicated in percentages, with the initial scroll position represented as 0%
and the end scroll position as 100%
. This is in contrast to the default document timeline, which continues progressing either for the specified duration or for the duration that the webpage remains open (if no duration is specified).
There are two ways to define a scroll timeline:
- Anonymous method
- Named method
Anonymous scroll progress timeline
The anonymous method is the default method and the quickest way to define a scroll timeline. If you use the scroll()
function and assign it as the value for the animation-timeline
property, the scroll progress timeline will select the nearest scroller ancestor and leverage its timeline:
animation-timeline: scroll();
The scroll()
function accepts two optional arguments: <scroller>
and <axis>
. The <scroller>
argument is used to reference the source (i.e., the scrollable element or container) and its scroll position that will drive the progress of the timeline. Here are its accepted values:
-
nearest
: Selects the nearest scrollable ancestor of the current element -
root
: Selects the root element of the document, such as the<html>
element -
self
: Selects the current element itself, if it is scrollable
The <axis>
argument is used to specify the scrollbar direction that will be used to provide the timeline. Here are its accepted values:
-
block
: Uses the measure of progress along the block axis of the scroll container -
inline
: Uses the measure of progress along the inline axis of the scroll container -
y
: Uses the measure of progress along the vertical axis of the scroll container -
x
: Uses the measure of progress along the horizontal axis of the scroll container
The scroll()
function's default value uses the nearest
scroller and block
axis values:
animation-timeline: scroll(nearest block);
The above code will bind the animation to the root
scroller on the block
axis.
The scroll progress timeline can be used for different scroll-aware designs, but the most practical use case is a reading progress indicator. This is easy to create using the anonymous scroll progress timeline, like so:
<main>
<section>
<article>
...
</article>
</section>
<div id="progress"></div>
</main>
#progress{
width: 100%;
height: 10px;
background-color: #c00bc0;
position: fixed;
top: 0;
left: 0;
transform-origin: 0 50%;
animation: progress linear;
animation-timeline: scroll();
}
In this code, we fix the reading progress indicator to the top
of the viewport and scale it forward and backward on the x-axis based on the scroller's position. Here’s the result:
Named scroll position timeline
The named scroll position timeline offers explicit control over scroller selection. Instead of relying on automatic lookup by the API, it allows you to assign unique identifiers, or names, to specific containers with scrollbars.
This approach aids in pinpointing and utilizing the scroll progress timeline of the identified container. This method is especially useful in cases where the webpage has multiple timelines and the anonymous automatic lookup doesn't suffice.
To implement a named scroll position timeline, set the scroll-timeline-name
property on the desired scroll container with a name value of your choice. Ensure the value has a --
prefix, like so:
scroll-timeline-name: --my-timeline;
Similar to the anonymous scroll timeline, you can adjust the axis using the scroll-timeline-axis
property. This property uses the same values as the <axis>
argument in the anonymous method:
scroll-timeline-axis: block;
Unlike the animation-timeline
property, you can combine the scroll-timeline-name
and scroll-timeline-axis
properties into a single shorthand property: scroll-timeline
. This means you can define both properties at the same time, instead of having to do so separately.
Here’s an example that demonstrates how named scroll progress works:
.source {
scroll-timeline-name: --my-timeline;
scroll-timeline-axis: inline;
}
You can simply write:
.source {
scroll-timeline: --my-timeline inline;
}
You can update the base markup of the previous demo and add another scroll container to the page:
<div id="galaxies" style="--num-images: 3">
<div id="galaxy-container">
<div class="galaxy_progress"></div>
<div class="andromenda galaxy_entry">
<img src="./andromeda.jpg" />
</div>
<div class="pinwheel galaxy_entry">
<img src="./pinwheel.jpg" />
</div>
<div class="milky-way galaxy_entry">
<img src="./milky.jpg" />
</div>
</div>
</div>
In this code, we create a galaxy-container
with three child images and a galaxy_progres
element, which will be positioned absolute to the galaxies
container.
Next, let’s give the galaxy-container
an identifier and an inline
axis using the scroll-timeline
shorthand property. Then, we’ll attach it to an animating element (this case, the galaxy_progress
element) using the animation-timeline
property:
#galaxy-container{
overflow-x: scroll;
scroll-snap-type: x mandatory;
display: flex;
scroll-timeline: --galaxies inline;
}
@keyframes galaxies_animation {
to { transform: scaleX(1); }
}
.galaxy_progress {
position: absolute;
top: 0;
left: 0;
width: 500px;
height: 10px;
background-color: #c00bc0;
transform: scaleX(calc(1 / var(--num-images)));
animation: auto galaxies_animation linear forwards;
animation-timeline: --galaxies;
}
Here we set an identifier, --inner_timeline
, to the .inner-container
and reference it with the animation-timeline
property on the .gallery-progress
animation. Here’s the result:
It may seem counterintuitive to use a named scroll progress timeline here, since an anonymous progress timeline could have easily used the nearest ancestor container with a scrollbar using the scroll(nearest inline)
argument (the .inner-container
, in this case). But, actually, since the .gallery-progress
is absolutely positioned, the animation timeline would have skipped the .inner-container
and used the root
scroller, which is the nearest ancestor that is relatively positioned.
The nearest
argument considers the element that can affect its size and position. Since the .gallery-progress
element is absolutely positioned, and its nearest parent is not relatively positioned, it will jump to the nearest ancestor that is relatively positioned and use its timeline.
Scroll progress timeline ranges
The default behavior of animations linked to the scroll and view timeline is to attach to the entire page's range. For the scroll timeline, this spans from 0%
to 100%
scrolled. For the view timeline, it covers the period from an element's initial visibility at one edge to its appearance at the opposite edge.
The animation-range
property comes into play when you need to customize these values and override the default behavior. You can use the animation-range
property, to bind a scroll or view timeline animation to specific start and end ranges on the timeline:
animation-range: <range-start> <range-end>;
If you only want an animation to run for the first 40%
of the webpage and end at 80%
, you can define it as follows:
animation-range: 40% 80%;
The animation-range
can also be specified in view height (vh
) values, like so:
animation-range: 40vh 80vh;
The animation-range
property accepts different values depending on the timeline it is used with. The example above will only work for the scroll progress timeline, as the specified values are within the range of the scroll progress timeline's start and end values. We’ll look at how to define an animation range for the view progress timeline later in this article.
View progress timeline
The view progress timeline is also linked to the scroll position of a container, but in relation to an element's progress within the container. Simply put, it’s the relative position of the element in the scroller that determines the progress of the timeline. The timeline progress begins the moment an element first intersects with the scroller and ends when the element no longer intersects the scroller.
The view progress timeline is similar to the Intersection Observer API in JavaScript. You can use both the anonymous and names methods to create the view progress timeline.
Anonymous view progress timeline
To create an anonymous view progress timeline, you’ll use the view()
function and pass it as the animation-timeline
value:
animation-timeline: view();
The view()
function accepts the <axis>
argument and an optional <view-timeline-inset>
argument. The <axis>
argument is identical to that used in the scroll()
function; it is used to define which axis to track, and it accepts the same values.
The <view-timeline-inset>
argument is used to specify an offset value to adjust the bounds when an element is considered to be in (or outside of) the view. The offset value can be positive or negative.
It’s easy to add a view progress timeline to any webpage animation using the anonymous method. As an example, you can use the following code to make the images
on our demo page fade in when they're scrolled into view:
@keyframes image-reveal {
from {
opacity: 0;
clip-path: inset(0% 30% 0% 30%);
}
to {
opacity: 1;
clip-path: inset(0% 0% 0% 0%);
}
}
.images {
animation: image-reveal both;
animation-timeline: view();
}
With this simple code, every image element on the webpage fades in as it intersects with the viewport:
Named view progress timeline
You can create a named view timeline using the same method employed for the scroll timeline. Just set an identifier (or name
) and an axis
with the same properties and values, but replace the scroll
prefix with view
:
.nested-container{
view-timeline-name: --image-reveal;
view-timeline-axis: block;
}
@keyframes image-reveal {
from {
opacity: 0;
clip-path: inset(0% 30% 0% 30%);
}
to {
opacity: 1;
clip-path: inset(0% 0% 0% 0%);
}
}
.nested-images{
animation: image-reveal both;
animation-timeline: --image-reveal;
}
This code will create the same visual output as the anonymous method, but for the nested scroller and its child images.
View progress timeline ranges
The animation range value varies depending on the timeline in use. In the case of the view timeline, it accepts a range name value rather than a percentage value. Here are the acceptable range name values for the view timeline:
-
cover
: Sets the view timeline range to values that ensure the element is fully covered by the scroller during the view timeline -
entry
: Sets the start value of the view timeline range to0%
and sets the end value to the percentage of the element’s visibility when it’s inside the scroller -
exit
: Sets the start value of the view timeline range to the percentage of the element’s visibility when it’s inside the scroller and sets the end value to100%
-
entry-crossing
: Sets the start value and end value of the view timeline range to the percentage of the element’s visibility when it is first visible at one edge of the scroller and when it is fully visible inside the scroller, respectively -
exit-crossing
: Sets the start value and end value of the view timeline range to the percentage of the element’s visibility when it‘s inside the scroller and when it reaches the opposite edge of the scrollbar, respectively -
contain
: Specifies that the animation will start and end within the bounds of the scrollport
These range names are often combined with a range offset when defining the range-start
and range-end
values for the animation-range
property. The range offset is a percentage value that is used to determine the position of an element in relation to the range name it is combined with.
For example, if you want an animation to start halfway the moment it intersects with the scrollport, set the range-start
value to entry 50%
. This way, the animation will start and progress to 50%
before it enters the viewport. The same approach applies to the range-end
value:
animation-range: entry 25% cover 50%;
Limitations
Browser compatibility is a significant issue that you’ll encounter when implementing scroll-aware UI via CSS. Most of the new CSS APIs, and even some old ones, that provide an improved scope for scroll-aware UI design are still experimental and do not yet have widespread support across different browsers.
For instance, at the time of writing, the scroll
and view
APIs are currently supported only in Chromium-based browsers as well as on Firefox browsers behind a feature flag.
Another limitation is the performance and accessibility concerns that often arise when dealing with scroll-driven animations. The complexity and frequency of animations can lead to issues such as jank, lag, or even battery drain on certain devices. Furthermore, these animations can interfere with user scrolling experience as well as accessibility tools like screen readers and keyboard navigation.
One approach to mitigate these issues is to optimize the animations to ensure smooth performance without compromising user experience. Additionally, providing options to disable animations can enhance accessibility for users who might find them disruptive.
Another effective strategy is to use JavaScript polyfills. Polyfills extend the functionality of CSS features, allowing them to work in browsers that do not have native support. You can use JavaScript polyfills to improve compatibility and ensure that scroll-driven animations work consistently across different browsers.
Extending capabilities with JavaScript
The new CSS features for scroll-driven animations are powerful and declarative, but they are not yet widely supported by all browsers. To ensure cross-browser compatibility and accessibility, you can use JavaScript to enhance or polyfill the scroll-driven animations.
Several libraries and frameworks can help with this task, such as ScrollTrigger, ScrollMagic, Framer Motion, and the good ol’ Intersection Observer API. These tools provide more options and flexibility to create complex and interactive scroll-driven animations with ease.
Another way to extend capabilities is to use the scroll-driven animation specification’s ScrollTimeline
and ViewTimeline
interfaces. You can use these classes to create scroll and view timeline animations using JavaScript, creating similar interactions as their CSS counterparts. They are useful for those who prefer using JavaScript and for cases where JavaScript is required for additional functionality.
The ScrollTimeline
and ViewTimeline
classes accept options such as the source
element, axis
, orientation
, range
, and range
of the timeline. You can also link these timelines to animations created with the Animation interface and control them with methods such as play
, pause
, resume
, cancel
, or reverse
.
To create a scroll and view timeline using JavaScript, create a new ScrollTimeline
or ViewTimeline
object using the constructor. Then, specify the source
element and any of the following optional properties: axis
, orientation
, and range
:
const timeline = new ScrollTimeline({
source: document.documentElement, // The document element as the source
axis: "block",
});
Here, we set the document element as the source
; this will bind the animation to the timeline of the root scroll position.
Next, create a new Animation
object using the animation
constructor. You can pass a keyframe
object to specify the animation properties and values, and an options
object to specify the animation duration
, easing
, and fill
.
You’ll also need to pass the ScrollTimeline
or ViewTimeline
object as the timeline option to link the animation to the timeline
:
const animation = new Animation(
/* The keyframe object*/
{
transform: ["rotate(0deg)", "rotate(360deg)"],
},
/* The options object*/
{
duration: 100,
fill: "both",
}
);
/*Link the animation to the timeline*/
animation.timeline = timeline;
Conclusion
The concept of scroll-aware UI refers to various interactive elements on a webpage that dynamically adjust, stick, or snap into position as the scrollbar thumb moves. In this article, we explored building scroll-aware UI with only CSS in order to provide more interactivity and better user experience without negatively impacting system performance.
There are many ways to implement scroll-aware designs for your web applications. The possibilities are virtually endless, limited only by one's imagination.
Happy hacking!
Is your frontend hogging your users' CPU?
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — Start monitoring for free.
Top comments (3)
Very interesting and thorough, but I can't see your GIFs and they don't have alt text. Overall, an awesome article!
Thanks for catching that! The article has been updated to include alt text.
Thanks for reading!
No problem! Keep up the good posts!