Opening: treating scroll as “time”

When you build scroll-based animations, you eventually hit this thought:

  • “What if scroll were not just an input event”
  • “but a proper timeline?”

Scroll-Driven Animation is that idea made explicit. Instead of treating scroll as a trigger, you treat it as the timeline itself.

In this post, I group implementation strategies into three families:

  1. Native CSS Scroll-Driven Animation
  2. IntersectionObserver for state changes
  3. scroll + requestAnimationFrame with manual progress mapping

For each one, we will cover the principle, strengths, tradeoffs, and accessibility concerns. The code samples here are minimal and correct by themselves, and each section ends with a link to a live demo you can poke at directly.

Two hands holding a smartphone against a dark background, one finger swiping the screen - a gesture repeated hundreds of times a day becomes the timeline for an animation
Two hands holding a smartphone against a dark background, one finger swiping the screen - a gesture repeated hundreds of times a day becomes the timeline for an animation
Photo by Gilles Lambert on Unsplash

Core principle: interpret scroll as progress from 0 to 1

All three approaches share the same mental model:

  • pick a reference (the page, a container, or a specific element’s visibility)
  • interpret “where we are” as a progress value between 0 and 1
  • bind that progress to animation

The main difference is who performs the binding:

  • CSS
  • the browser’s observer (IntersectionObserver)
  • or your own JavaScript mapping
Diagram of reading scroll as progress - on the left, a reference area (the page, a container, or an element's visible range) captures what is currently in view; in the middle that is read as a progress value between 0 and 1 (0.62 in the diagram); on the right it connects to properties like opacity, transform, and width. Below, a summary splits who performs that connection into three branches - CSS via animation-timeline, the browser's observer via IntersectionObserver, and our own scroll plus rAF mapping
Diagram of reading scroll as progress - on the left, a reference area (the page, a container, or an element's visible range) captures what is currently in view; in the middle that is read as a progress value between 0 and 1 (0.62 in the diagram); on the right it connects to properties like opacity, transform, and width. Below, a summary splits who performs that connection into three branches - CSS via animation-timeline, the browser's observer via IntersectionObserver, and our own scroll plus rAF mapping
The core idea: interpret scroll as progress and bind it to animation

Let’s walk through them.


1) Native CSS Scroll-Driven Animation

How it works

A normal CSS animation runs on time: write animation-duration: 2s and it takes two seconds. Scroll-driven animation swaps that time axis for a scroll axis. That is really all there is to it.

The property that swaps the axis is animation-timeline, and its values come in two flavors.

Anonymous timelines (no name needed)

  • scroll() uses a scroll container’s scroll progress as time. Inside the parentheses you can name the scroller (nearest, root, self) and the axis (block, inline, x, y), e.g. scroll(root block).
  • view() uses the span during which the element moves through the scrollport as 0 to 1. It fits reveal effects.

Named timelines

  • Declare scroll-timeline: --name or view-timeline: --name on the reference element.
  • Then call it from the animated element with animation-timeline: --name.
  • The name must start with -- (CSS’s <dashed-ident> rule). Writing page-scroll without the dashes makes the value invalid, and it is silently ignored.

An earlier draft also had an @scroll-timeline at-rule, but it disappeared when the spec was rewritten. Everything today is a plain property. Copying @scroll-timeline from an older blog post will simply not work.

Small, reliable example: a reading progress bar

html
<div class="sda-progress" aria-hidden="true"></div>
css
.sda-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: linear-gradient(90deg, #0ea5e9, #22c55e);
  transform-origin: 0 50%;
  transform: scaleX(0);
  animation: sda-fill linear both;
  /* Scroll decides the progress, so the duration value itself is
     meaningless. It just must not be 0, hence the customary 1ms. */
  animation-duration: 1ms;
  animation-timeline: scroll(root block);
}

@keyframes sda-fill {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

This binds page scroll progress directly to scaleX without JavaScript.

The named-timeline version looks like this. You need it when the scroll container is a specific box rather than the document root.

css
.article-scroller {
  overflow-y: auto;
  scroll-timeline: --article-scroll block;
}

.sda-progress {
  /* ...same as above... */
  animation-timeline: --article-scroll;
}

Pros

  • clean, declarative intent
  • little to no JavaScript
  • the “scroll as timeline” idea is visible in the code
  • when you only touch compositable properties like transform and opacity, the animation can run off the main thread without any scroll handler — something the Chrome team highlights as the key win of the native approach

Cons / caveats

  • browser support must be checked carefully
  • the syntax may still feel unfamiliar in teams
  • you may need a fallback strategy
  • you cannot pin something in place once it has appeared

That last one ties straight into accessibility, so it is worth unpacking. An animation bound to scroll progress is mapped to position. The state produced at progress 0.6 comes back identically at 0.6, and scrolling back to 0 rewinds it to the start.

animation-fill-mode: forwards looks like the fix but is not. fill-mode only decides the state outside the timeline range; it does nothing about rewinding inside it.

So if you want “content someone has already read never disappears again” — a real courtesy to anyone scrolling back up — native CSS alone will not get you there. You need the IntersectionObserver or rAF approach below, where you simply never turn the state back off.

Browser support: treat it as a strategy question

Native scroll-driven CSS has left the flag-only phase behind, but it is not something every browser can run yet. Here is where it stands as of August 2026.

데이터 표
Browseranimation-timeline, scroll(), view()
Chrome, EdgeSupported since 115 (July 2023)
Safari (macOS, iOS)Supported since 26 (September 2025)
FirefoxNot in stable releases. Enabled by default only in Nightly; other channels need layout.css.scroll-driven-animations.enabled flipped in about:config

It is worth knowing the word Baseline here. It is the label that says how broadly a web feature works across the major browsers, in three tiers: limited availability, newly available, and widely available. Because Firefox is missing, scroll-driven animation is still at limited availability.

So “everyone supports it now, just ship it” is premature. In practice, I usually take this shape:

  1. use animation-timeline/view-timeline as the first path
  2. let older browsers fall back without breaking

An @supports guard helps keep that intent explicit:

css
.progress-fallback {
  transform: scaleX(1);
}

@supports (animation-timeline: scroll()) {
  .progress-fallback {
    transform: scaleX(0);
    animation: sda-fill linear both;
    animation-duration: 1ms;
    animation-timeline: scroll(root block);
  }
}

There is one trap almost everyone hits once.

The animation shorthand resets animation-timeline back to its initial value, auto. Leaving the timeline out of the shorthand does not preserve it; it wipes it. Reverse the order and your timeline quietly disappears.

css
/* Wrong order — the timeline is erased */
.bar {
  animation-timeline: scroll(root block);
  animation: sda-fill linear both;
}

/* Right order — shorthand first, timeline after */
.bar {
  animation: sda-fill linear both;
  animation-timeline: scroll(root block);
}

And while animation-duration does not control how fast a scroll-driven animation progresses, a value of 0 can stop it from running at all in some browsers. That is why 1ms is the customary placeholder.

Do not rely on a single source for support data — check caniuse, MDN, and Web Platform Status together. A table that mixes in a preview channel and gets read as all-green will bite you after you ship.

Support status for CSS Scroll-Driven Animation as of August 2026 - Baseline is limited availability. Chrome and Edge support it from 115 and Safari from 26, while Firefox has not shipped it in a stable release and needs a flag in Nightly. The chart also warns that some support tables paint Firefox green because the Nightly column is filled with preview data, and concludes that the right question is not whether it works but what shows up when it does not
Support status for CSS Scroll-Driven Animation as of August 2026 - Baseline is limited availability. Chrome and Edge support it from 115 and Safari from 26, while Firefox has not shipped it in a stable release and needs a flag in Nightly. The chart also warns that some support tables paint Firefox green because the Nightly column is filled with preview data, and concludes that the right question is not whether it works but what shows up when it does not
Support data: MDN · Web Platform Status (checked 2026-08)

Try it — native CSS basic · the direction limitation · with reduced motion · unsupported-browser fallback

Nothing animates on a stable Firefox release. That is expected — the fourth link shows what people see instead.


2) IntersectionObserver: change state when visible

How it works

IntersectionObserver lets the browser efficiently tell you whether (and how much) an element intersects the viewport. It shines when you care about state transitions rather than continuous progress.

Example: reveal-on-scroll

html
<section class="io-section">
  <h2 class="io-reveal">A gentle reveal driven by scroll</h2>
  <p class="io-reveal">IntersectionObserver is great for state changes.</p>
</section>
css
.io-reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 500ms ease, transform 500ms ease;
  will-change: opacity, transform;
}

.io-reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .io-reveal,
  .io-reveal.is-visible {
    transition: none;
    transform: none;
    opacity: 1;
  }
}
js
const revealEls = document.querySelectorAll(".io-reveal");

const observer = new IntersectionObserver(
  (entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add("is-visible");
      obs.unobserve(entry.target);
    }
  },
  {
    threshold: 0.2,
  }
);

for (const el of revealEls) observer.observe(el);

Pros

  • strong performance characteristics
  • ideal for enter/exit/activate patterns
  • widely supported and stable

Cons / caveats

  • less suited for continuous scroll progress
  • often ends up as class toggles + CSS transitions
  • complex timeline choreography is harder

Try it — IntersectionObserver basic · toggle on scroll direction · with reduced motion

The third one never hides an element once it has appeared, so text does not vanish on someone scrolling back up.


3) scroll + requestAnimationFrame: map progress yourself

How it works

This is the classic, most flexible approach.

requestAnimationFrame (rAF for short) is an API that tells the browser, “run this function once, right before you paint the next frame.” A scroll event can fire multiple times within a single frame, so deferring the calculation to rAF lets you cap the work at once per frame.

  1. read scroll-related geometry
  2. compute progress for a section
  3. map that progress to styles

It gives you precise control, but you also own performance and accessibility details.

Example: scale a card across a scroll range

html
<section class="raf-stage">
  <div class="raf-card" data-raf-card>Scroll drives me</div>
</section>
css
.raf-stage {
  min-height: 160vh;
  display: grid;
  place-items: center;
  padding: 24vh 0;
}

.raf-card {
  width: min(680px, 92vw);
  padding: 48px;
  border-radius: 20px;
  background: #0f172a;
  color: #e5e7eb;
  font-weight: 700;
  font-size: clamp(28px, 4vw, 44px);
  box-shadow: 0 30px 80px rgba(15, 23, 42, 0.35);
  transform: scale(0.92);
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

@media (prefers-reduced-motion: reduce) {
  .raf-card {
    transform: none !important;
    opacity: 1 !important;
  }
}
js
const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (card && !reduceMotion) {
  let ticking = false;

  const clamp01 = (v) => Math.min(1, Math.max(0, v));

  const update = () => {
    ticking = false;

    const rect = card.getBoundingClientRect();
    const viewportH = window.innerHeight;

    // Normalize: from entering at the bottom to exiting at the top
    const start = viewportH;
    const end = -rect.height;
    const progress = clamp01((start - rect.top) / (start - end));

    const scale = 0.92 + progress * 0.08;
    const opacity = 0.6 + progress * 0.4;

    card.style.transform = `scale(${scale.toFixed(4)})`;
    card.style.opacity = opacity.toFixed(4);
  };

  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  };

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onScroll);
  update();
}

Pros

  • maximum expressive freedom
  • any math mapping is possible
  • easy to integrate into existing codebases

Cons / caveats

  • performance pitfalls are on you
  • accessibility is easy to forget
  • maintainability cost can climb quickly

Try it — scroll + rAF basic · direction toggle · with reduced motion

The progress value is shown on screen as a percentage — scroll and watch it move. Neither of the previous two approaches gives you that number.


React implementation: wrap the rAF pattern cleanly

In React, this usually works well:

  • capture the target with a ref
  • register scroll handlers inside useEffect
  • short-circuit everything when prefers-reduced-motion is on

This example ports the manual progress mapping into a React component.

tsx
import { useEffect, useRef } from "react";

function clamp01(v: number) {
  return Math.min(1, Math.max(0, v));
}

export default function ScrollMappedCard() {
  const cardRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const card = cardRef.current;
    if (!card) return;

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduceMotion) {
      // Skip the motion and show the final state right away.
      card.style.transform = "none";
      card.style.opacity = "1";
      return;
    }

    let rafId = 0;

    const update = () => {
      rafId = 0;

      const rect = card.getBoundingClientRect();
      const viewportH = window.innerHeight;

      const start = viewportH;
      const end = -rect.height;
      const progress = clamp01((start - rect.top) / (start - end));

      const scale = 0.92 + progress * 0.08;
      const opacity = 0.6 + progress * 0.4;

      card.style.transform = `scale(${scale.toFixed(4)})`;
      card.style.opacity = opacity.toFixed(4);
    };

    const onScroll = () => {
      if (rafId) return;
      rafId = window.requestAnimationFrame(update);
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    update();

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      // Cancel a frame that may still be queued after unmount.
      if (rafId) window.cancelAnimationFrame(rafId);
    };
  }, []);

  return (
    <section style={{ minHeight: "160vh", display: "grid", placeItems: "center" }}>
      <div
        ref={cardRef}
        style={{
          width: "min(680px, 92vw)",
          padding: "48px",
          borderRadius: "20px",
          background: "#0f172a",
          color: "#e5e7eb",
          fontWeight: 700,
          fontSize: "clamp(28px, 4vw, 44px)",
          transform: "scale(0.92)",
          transformOrigin: "50% 50%",
          willChange: "transform, opacity",
        }}
      >
        Scroll drives me
      </div>
    </section>
  );
}

The upside is clarity:

  • binding and cleanup are explicit
  • the effect stays contained within the component

The tradeoff is also clear:

  • you still touch the DOM directly
  • performance matters even more when multiple effects stack

Even in React, the same rule holds: principle + cost + accessibility should travel together.

Try it There is no React demo yet. The mapping logic is identical, so check the behaviour in the vanilla JS demo and port the hook above as-is.


How I choose in practice

My default decision rules look like this:

Cards comparing the three implementation approaches side by side - native CSS is great when scroll is literally the timeline and needs no JavaScript, but you must confirm browser support first; IntersectionObserver excels at reveal, activation, and trigger patterns with simple code, but it cannot give you progress as a continuous value; direct scroll and rAF mapping can do anything you want but needs the most code, and gets janky if written carelessly. At the bottom, a conclusion notes that real products usually mix all three
Cards comparing the three implementation approaches side by side - native CSS is great when scroll is literally the timeline and needs no JavaScript, but you must confirm browser support first; IntersectionObserver excels at reveal, activation, and trigger patterns with simple code, but it cannot give you progress as a continuous value; direct scroll and rAF mapping can do anything you want but needs the most code, and gets janky if written carelessly. At the bottom, a conclusion notes that real products usually mix all three
It is less about which is best, and more about which matches the job
  • scroll is the timeline -> consider native CSS first
  • enter/exit/trigger is the goal -> IntersectionObserver
  • precise choreography or custom math -> rAF mapping

In real products, mixing them is often best:

  • CSS for a global progress bar
  • IntersectionObserver for section reveals
  • rAF only for a special hero section

Accessibility concerns: check these before shipping

So far this has all been about how to build the effect. But there is one more thing we cannot skip.

Scroll animations can easily become “beautiful obstacles.” These are the main things I try to guard against.

Four accessibility guardrails for scroll-driven animation - first, respect motion sickness and fatigue with prefers-reduced-motion; second, protect reading flow by avoiding structures where content only appears once an animation finishes; third, check focus and keyboard use so a Tab-focused element never gets hidden off-screen; fourth, treat performance as accessibility by animating mainly transform and opacity so the browser can composite them
Four accessibility guardrails for scroll-driven animation - first, respect motion sickness and fatigue with prefers-reduced-motion; second, protect reading flow by avoiding structures where content only appears once an animation finishes; third, check focus and keyboard use so a Tab-focused element never gets hidden off-screen; fourth, treat performance as accessibility by animating mainly transform and opacity so the browser can composite them
Guardrails to check before the flashy effect

1) motion sensitivity: treat prefers-reduced-motion as a default path

prefers-reduced-motion is the media query that tells you whether the user turned on “reduce motion” in their OS settings. For people with vestibular disorders, large movement can genuinely cause nausea and dizziness, so this is closer to a physical requirement than a taste setting.

  • in CSS, use @media (prefers-reduced-motion: reduce)
  • in JS, short-circuit the animation logic with matchMedia

All code samples above already include this.

There is a standard to anchor it to. WCAG 2.2 success criterion 2.3.3 Animation from Interactions (Level AAA) says that motion animation triggered by interaction must be able to be disabled, unless the animation is essential to the functionality or the information being conveyed. Scrolling is an interaction, so this lands squarely on us. Content simply moving into view as you scroll is considered essential to scrolling itself; it is the parallax and zoom effects layered on top that fall under the criterion.

Level AAA is above the bar most legal requirements set (usually AA), but the risk the criterion points at is ours either way once we ship scroll animation.

One caveat about the code below: reading matchMedia(...).matches only once misses users who change the setting while the page is open. To be strict, listen for changes with mql.addEventListener("change", ...) as well. I keep the single read here to make the flow easier to follow.

Before and after: actually honoring reduced motion

Code that “looks fine” but skips the accessibility path is common:

Before:

js
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.add("is-visible");
  }
});

document.querySelectorAll(".io-reveal").forEach((el) => observer.observe(el));

After:

js
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targets = document.querySelectorAll(".io-reveal");

if (reduceMotion) {
  // Drop the motion, but leave the content immediately readable.
  targets.forEach((el) => el.classList.add("is-visible"));
} else {
  const observer = new IntersectionObserver((entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add("is-visible");
      obs.unobserve(entry.target);
    }
  }, { threshold: 0.2 });

  targets.forEach((el) => observer.observe(el));
}

The rAF mapping splits the same way.

Before:

js
const card = document.querySelector("[data-raf-card]");
window.addEventListener("scroll", () => {
  const rect = card.getBoundingClientRect();
  const progress = (window.innerHeight - rect.top) / (window.innerHeight + rect.height);
  card.style.transform = `scale(${0.92 + progress * 0.08})`;
});

After:

js
const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (card && !reduceMotion) {
  let ticking = false;
  const clamp01 = (v) => Math.min(1, Math.max(0, v));

  const update = () => {
    ticking = false;
    const rect = card.getBoundingClientRect();
    const start = window.innerHeight;
    const end = -rect.height;
    const progress = clamp01((start - rect.top) / (start - end));
    const scale = 0.92 + progress * 0.08;
    card.style.transform = `scale(${scale.toFixed(4)})`;
  };

  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  };

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onScroll);
  update();
}

2) do not break reading order

Scroll effects often distort meaning:

  • something appears visually later
  • but is read earlier by assistive tech

Try to keep semantic order and visual order aligned as much as possible. Animation should support meaning, not scramble it.

3) focus and keyboard flows

Avoid these patterns:

  • interactive elements disappearing with display: none on scroll
  • focus moving somewhere that is visually “gone”

Safer patterns:

  • prefer opacity/visibility to manage presence
  • do not sacrifice focusable UI to a scroll effect

4) performance is also accessibility

Jank hurts everyone, but it hurts some users more.

  • animate transform and opacity when possible
  • combine passive scroll listeners with rAF
  • keep layout reads (getBoundingClientRect) deliberate

The will-change in the samples above is a hint telling the browser “this property is about to change,” so it can prepare a separate layer. Handy, but leaving it on hundreds of elements permanently just eats memory. Remove it once the effect is done, or add it only right before the animation starts.


Demos and external sharing: make it reproducible

With scroll animation, reading about it and actually scrolling it are unusually different experiences. So each section ends with a demo, and they all live in one place.

Each approach ships the same result in three variants, so you can compare them side by side.

  • basic — the simplest form, elements reacting to scroll
  • direction / limitation — deliberately exposing what that approach cannot do (native CSS has no idea which way you are scrolling)
  • with reduced motion — respecting prefers-reduced-motion, and never re-hiding content once it has appeared (with one exception: native CSS cannot pin content this way. An animation bound to scroll progress always rewinds when you scroll back up. Only the IntersectionObserver and rAF demos genuinely lock.)

Native CSS has one extra: what unsupported browsers show. Open it in a stable Firefox release and you will get the static version — the point is to confirm that this is the design, not a bug.


At a glance

  • All three approaches do the same job: read scroll as a progress value between 0 and 1 and bind it to animation. The difference is who performs that binding.
  • Native CSS (animation-timeline: scroll(), view()) needs almost no JavaScript, but with Firefox missing from stable releases, Baseline still rates it limited availability. Wrap it in @supports and decide what the fallback should look like.
  • IntersectionObserver is strong for state transitions like reveal and activation, and it is broadly supported. It just is not built for continuous progress values.
  • scroll + rAF can map anything you want, but you own performance and accessibility. The default is a passive listener paired with rAF, computing once per frame.
  • Whatever you pick, treat prefers-reduced-motion as a default behavior (this is the risk WCAG 2.3.3 points at). But pinning content in place is a JS-only move — a CSS animation bound to scroll progress always rewinds on the way back up.

Closing: scroll is not just input, it can be time

Scroll can be more than an event; it can be a timeline.

The key is not which tool looks flashiest, but whether you understand the principle, the tradeoffs, and the accessibility costs together. When you carry all three, scroll-driven animation becomes a much more trustworthy tool.