Articles /
Retrocompatible CSS Scroll-Timeline Animations
I didn’t expect my first blog post to be about CSS—but here we are.
For years, I relied on Framer Motion to handle animations triggered on scroll.
Recently, I migrated my website to use an emerging CSS feature: Scroll-Timeline.
This post explains why and how I did it, how I kept a fallback in place using motion
, and how I used CSS variables to simplify both coding and retro-compatibility.
Here we need multiple variables that are all derived from scroll. For the purpose of a simple example, I will just show a few in this article.
Why Replace motion
?
While motion
is a great library for complex/interactive animations (still use it to animate the logo for instance), I wanted to move scroll-based logic out of JavaScript and into CSS.
This brings:
- Better performance (no reflows or DOM updates on scroll)
- Simpler code
- Native CSS animations that follow the browser's rendering pipeline
The --scroll-progress-root
CSS Variable
The base of the solution here, to tackle both retro-compatibility and reducing quantity of code, is to:
- Have a
--scroll-progress-root
CSS variable exposing the current scroll.- If
Instead of
@keyframes;
To expose the scroll as a variable, I define a CSS custom property and animate it:
@property --scroll-progress-root {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@keyframes header-scroll-progress {
0% {
--scroll-progress-root: 0;
}
100% {
--scroll-progress-root: 100000;
}
}
.scroll-progress-provider {
animation: header-scroll-progress linear both;
animation-timeline: scroll(root block);
animation-range: 0px 100000px;
}
The large number ensures the variable updates even on long pages. It’s not elegant, but effective.
Fallback with motion
If the browser doesn't support Scroll-Timeline, I still use motion
to expose the same scroll value as a CSS variable. The rest of the logic stays the same, since all animations are expressed with CSS variables.
<motion.div style={{
"--bg-opacity": useTransform(scrollY_, [130, 180], [0, 0.8]),
"--header-blur": useTransform(scrollY_, [150, 220], ["0px", "9px"]),
"--border-opacity": useTransform(scrollY_, [180, 220], [0, 0.8]),
"--navbar-opacity": useTransform(scrollY_, (y) => (y > 200 ? 0 : 1)),
}}>
Mapping Scroll to Styles
With the scroll exposed as a variable, we can map it to style values using simple functions:
<div style={{
"--bg-opacity": map([130, 180], [0, 0.8]),
"--header-blur": map([150, 220], [0, 9], "px"),
"--border-opacity": map([160, 220], [0, 90], "%"),
"--navbar-opacity": threshold(200, [100, 0], "%"),
}}>
All style logic is now driven by CSS variables—either animated directly with Scroll-Timeline or fed from JavaScript when unsupported.
Function Mapping Examples
I used different scroll/view mappings to animate properties like height and opacity. Here are some examples:
Clamped Linear Ramps
One of the most common mappings is a clamped linear ramp:
This structure is useful for scroll-based animations because you can precisely control when a style begins and ends changing.
Smooth Alternatives
If you want a more natural animation, you can use smoothstep-like functions:
Conclusion
Is all of this overkill for a simple scroll animation? Maybe.
But it was a great opportunity to:
- Learn how to use Scroll-Timeline
- Practice function mapping
- Avoid unnecessary JavaScript
- Improve performance
Of course, here, the usage is really primitive, but it is important to understand the basics before diving into more complex scenarios.
And above all, it was fun.