A scroll progress indicator is a common user interface pattern that visually communicates the user's progress through a scrollable document or container. As the user scrolls, a visual element updates to reflect their position, providing a clear and intuitive sense of how much content has been viewed and how much remains.
How to implement
To create a scroll progress indicator, you need two things:
- An element to act as the progress bar. This element is typically
position: fixedorposition: absoluteso that it stays in view while the user scrolls. - An animation that is linked to the scroll position.
Here’s how you can achieve this:
- First, create an HTML element that will serve as your progress bar. This element can be styled to your liking.
- Next, in your CSS, define a
@keyframesanimation that scales the progress bar. A common approach is to scale the element fromscaleX(0)toscaleX(1). - Finally, apply this animation to your progress bar element and set its
animation-timelineto a scroll-timeline. This tells the browser to drive the animation's progress based on the scroll position of the nearest ancestor scroller.
Example code
This code grows the #progress element on scroll using an anonymous scroll-timeline, created by the scroll() function.
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: scroll())) {
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
#progress {
position: fixed;
left: 0; top: 0;
width: 100%; height: 1em;
background: red;
transform-origin: 0 50%;
animation: grow-progress auto linear;
animation-timeline: scroll();
}
}
}
Because of its location in the DOM, the scroll() function will track its nearest ancestor scroller in the block direction, which here is the root scroller.
<body>
<!-- MANDATORY: Purely decorative visual scroll progress bars MUST set aria-hidden="true" to remove the empty element from the assistive technology reading tree -->
<div id="progress" aria-hidden="true"></div>
</body>
This code grows the #progress element on scroll using a named scroll-timeline, created by the scroll-timeline property.
@media (prefers-reduced-motion: no-preference) {
@supports ((animation-timeline: scroll())) {
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
:root {
scroll-timeline: --tl block;
}
#progress {
position: fixed;
left: 0; top: 0;
width: 100%; height: 1em;
background: red;
transform-origin: 0 50%;
animation: grow-progress auto linear;
animation-timeline: --tl;
}
}
}
Best Practices
When using scroll-driven animations, it's important to follow a few best practices to ensure a smooth and accessible experience:
- DO include feature detection: Not all browsers support scroll-driven animations. Use
@supports (animation-timeline: scroll())to check for support and provide a fallback for browsers that don't support it.- DO NOT use the
scroll-timeline-polyfillpackage for the fallback strategy as it is not feature complete and has a lot of known issues. - If the animation is only considered to be decorative, opt for Progressive Enhancement and DO NOT provide a fallback.
- DO NOT use the
- DO remove purely decorative elements from assistive technology reading flows: Apply
aria-hidden="true"to purely visual scroll indicators to ensure screen readers do not encounter empty, unnamed nodes. - DO respect user preferences: Some users prefer to have less motion on the web. Use the
prefers-reduced-motionmedia query to disable or reduce your animations for these users. - DO try to animate only performant CSS properties: For the smoothest animations, stick to animating properties that can be handled by the browser's compositor thread, such as
transformandopacity. Animating other properties likewidthorheightcan lead to performance issues. - DO use the correct declaration order: When using the
animationshorthand property, declareanimation-timelineafter it to prevent the shorthand from resetting the timeline.
When using the scroll() function to create a scroll-driven animation:
- OPTIONAL be explicit about the scroller: When not targeting the nearest ancestor scroller, be explicit about which scroller you want to use with
scroll(root)orscroll(self).- When
root,nearest, orselfare not sufficient, use a named scroll-timeline.
- When
- OPTIONAL be explicit about the axis to track: When not targeting the default
blockaxis (such as in a horizontal scroller), be explicit about which axis to track withscroll(block)orscroll(inline).
When using the scroll-timeline property to create a scroll-driven animation:
- DO use a CSS
<dashed-ident>for the name. - OPTIONAL be explicit about the axis to track: When not targeting the default
blockaxis (such as in a horizontal scroller), be explicit about which axis to track withscroll-timeline-axis. - DO make sure the scope of the lookup works: When the element that is declaring the
scroll-timelineis not a flat tree ancestor of the animated element, hoist up the visibility of thescroll-timeline’s name by usingtimeline-scopeon a shared ancestor.
Fallback strategies
For browsers that do not support scroll-driven animations, you can use a fallback to recreate the visual effects. The fallbacks are typically built with either a scroll listener (for ScrollTimeline effects) or the IntersectionObserver API (for ViewTimeline effects).
In browsers with built-in support for scroll-driven animations, ALWAYS use the native CSS implementation as those are more performant.
Note that not every effect can be recreated using the fallbacks approach.
For this use-case specifically, the following script applies the fallback for browsers that do not support scroll-driven animations. It uses a scroll listener to track the scroll position of the root element and updates the transform property of the progress bar accordingly.
<script>
if (!CSS.supports('animation-timeline', 'scroll()')) {
const progress = document.querySelector('#progress');
window.addEventListener('scroll', () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
const scrolled = window.scrollY;
const progressPercentage = (scrolled / scrollable);
progress.style.transform = `scaleX(${progressPercentage})`;
});
}
</script>