Articles /

2025
MAY
05

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:

f(x)={y1if xalinear from y1 to y2if a<x<by2if xbf(x) = \begin{cases} y_1 & \text{if } x \leq a \\ \text{linear from } y_1 \text{ to } y_2 & \text{if } a < x < b \\ y_2 & \text{if } x \geq b \end{cases}

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.