CreaTech
LAB-004CSS onlyScrollExperimental

Scroll-driven progress ring with animation-timeline in CSS

A ring that fills as you scroll, driven by the CSS scroll-driven animations API, with a scroll-listener fallback where it is not supported.

Built 4 Aug 2026 · Last verified 4 Aug 2026

The short version

A scroll-driven progress ring is an SVG circle whose stroke-dashoffset animates from full to empty, with animation-timeline: scroll() in place of a duration. The browser drives the keyframes from scroll position rather than from time, so it runs off the main thread and cannot stutter when JavaScript is busy. Where the API is missing, a small scroll listener writes the same progress value into a custom property and the ring behaves identically.

LAB-004live

How it works

  1. The ring is one SVG circle with pathLength set to 1. That normalises the geometry, so stroke-dasharray and stroke-dashoffset can be written as plain 0-to-1 fractions instead of computing 2 pi r by hand.
  2. A keyframe animation moves stroke-dashoffset from 1 to 0. On its own this is an ordinary CSS animation and would run on a timer.
  3. animation-timeline: scroll(nearest block) replaces that time-based timeline with the scroll progress of the nearest scrolling ancestor. animation-duration becomes meaningless: the animation is now mapped across the full scroll range of the box.
  4. Scoping to nearest rather than root is what makes this demo self-contained. A page-level reading-progress bar would use scroll(root block) instead; view() is the third option, for an element's own progress through the viewport.
  5. The rule sits inside @supports (animation-timeline: scroll()), so browsers without the API never apply it and never get a ring stuck at zero.
  6. The fallback does the same job with a scroll listener that writes a 0-to-1 value into --progress, which stroke-dashoffset reads directly. Same visual, more main-thread work, and only shipped where it is actually needed.

Scroll handlers were always a workaround

For fifteen years, tying anything to scroll position meant a scroll listener, a requestAnimationFrame throttle to stop it firing more often than the browser can paint, and a layout read on every frame. It works, and it has one structural flaw you cannot engineer around: it runs on the main thread.

Scroll itself does not. Browsers scroll on the compositor, which is why a page keeps scrolling smoothly while JavaScript is busy. Anything you drive from a scroll handler is therefore always one thread behind the thing it is following. On a fast machine you never notice. On a mid-range Android with a hydration pass in flight, you get a progress bar that lurches.

Scroll-driven animations move the whole relationship to CSS. You write ordinary keyframes, then swap the timeline:

animation-timeline: scroll(nearest block);

The animation is now driven by scroll position rather than by elapsed time, and it runs where scroll runs. There is no listener, no throttle and no layout read, and it cannot fall behind, because nothing is chasing anything.

pathLength is the underrated part

An SVG progress ring is a circle with stroke-dasharray set to its own circumference and stroke-dashoffset animated from that circumference down to zero. The usual version of this computes 2 * Math.PI * r in JavaScript and writes it into the DOM, which means the geometry lives in two places and changing the radius means changing both.

pathLength="1" tells the browser to pretend the path is one unit long. Every dash value becomes a plain fraction: stroke-dasharray: 1 and a stroke-dashoffset that runs from 1 to 0. The radius becomes a purely visual decision that no other code depends on.

It works on any SVG shape, not just circles, so the same keyframes drive a progress path of any shape you like.

scroll(), view() and picking the right one

scroll() tracks a scroll container's own progress: how far down the box you are. view() tracks an element's progress through the viewport: where it is on its journey from entering to leaving. They are easy to confuse and they solve different problems.

A reading-progress bar for an article wants scroll(root block). An element that should fade in as it arrives wants view(). This demo wants scroll(nearest block), because the ring is measuring a scroll box on the page rather than the page itself.

That last choice is why this experiment is self-contained. Tied to the document, the ring would fill as you scrolled this write-up, which would be a fine effect and a useless demo: you could not see the cause and the result at the same time.

The ring is a sibling of the scroll box rather than a child of it, so nearest would resolve to the page. The fix is a named timeline: the box publishes one with scroll-timeline-name, an ancestor makes it visible with timeline-scope, and the ring subscribes by name. That indirection is the part of the API that takes longest to internalise, and it is what lets the animated element live anywhere in the tree.

The fallback, and being honest about it

Safari and Firefox do not support this yet. That is not a reason to avoid the API, but it is a reason not to pretend otherwise, so this page tells you which version you are looking at.

The native path is wrapped in @supports (animation-timeline: scroll()). The fallback is a scroll listener writing a 0-to-1 value into --progress, which stroke-dashoffset reads directly. Same ring, same geometry, roughly ten lines, and it only does any work in the browsers that need it.

Two details make this layering work. The animation and the fallback declaration can coexist safely, because a running CSS animation outranks a normal declaration in the cascade: where the API exists, the animation simply wins. And the JavaScript checks CSS.supports rather than sniffing the browser, so the day Safari ships it, this page starts using the native path with no change from me.

On reduced motion

I have left the ring running under prefers-reduced-motion, and that is a deliberate call rather than an oversight.

The setting exists for motion the visitor did not ask for: things that move on their own, loop, or animate on arrival. A scroll-driven animation only advances while the visitor is scrolling, and it stops the instant they stop. It is closer to a scrollbar than to a carousel.

What is disabled is the entrance transition on the panel around it, which is unrequested motion by any reading.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import { useEffect, useRef, useState } from "react";
import styles from "./Demo.module.scss";

/**
 * Whether the browser can drive the animation from scroll position itself.
 * Feature detection, not browser sniffing: the day Safari ships this, the
 * native path starts being used with no change here.
 */
const NATIVE_TIMELINE =
  typeof CSS !== "undefined" && CSS.supports("animation-timeline: scroll()");

export default function ScrollProgressRing() {
  const box = useRef<HTMLDivElement>(null);
  const [pct, setPct] = useState(0);

  useEffect(() => {
    const el = box.current;
    if (!el) return;

    // The percentage readout is text, so it is wired up either way. The
    // fallback below is the only part that also drives the ring.
    const onScroll = () => {
      const max = el.scrollHeight - el.clientHeight;
      const p = max > 0 ? el.scrollTop / max : 0;
      setPct(Math.round(p * 100));
      if (!NATIVE_TIMELINE) el.style.setProperty("--progress", String(p));
    };

    onScroll();
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  return (
    <div className={styles.wrap}>
      <div
        ref={box}
        className={styles.scroller}
        tabIndex={0}
        role="region"
        aria-label="Scrollable sample content"
      >
        <div className={styles.inner}>
          {Array.from({ length: 8 }, (_, i) => (
            <p key={i} className={styles.line}>
              {i + 1}. Keep scrolling. The ring on the right is driven by this
              box, not by the page.
            </p>
          ))}
        </div>
      </div>

      <div className={styles.readout}>
        <svg className={styles.ring} viewBox="0 0 100 100" aria-hidden>
          <circle className={styles.track} cx="50" cy="50" r="42" />
          {/* pathLength normalises the geometry to 1, so dash values are plain
              fractions instead of 2 pi r worked out by hand. */}
          <circle
            className={styles.value}
            cx="50"
            cy="50"
            r="42"
            pathLength={1}
          />
        </svg>
        <p className={styles.pct}>
          {pct}%{" "}
          <span className={styles.mode}>
            {NATIVE_TIMELINE ? "scroll()" : "fallback"}
          </span>
        </p>
      </div>
    </div>
  );
}
Demo.module.scssscss
.wrap {
  display: flex;
  align-items: center;
  gap: clamp(16px, 4vw, 32px);
  padding: clamp(20px, 4vw, 36px);

  /* Lets the ring, which is a sibling rather than a descendant of the
     scroller, see the timeline the scroller publishes. */
  timeline-scope: --lab-ring-scroll;

  /* Against the stage, not the viewport: this demo is a third of the width in
     the homepage strip and full width on its own page. */
  @container stage (max-width: 420px) {
    flex-direction: column;
    gap: 10px;
    padding: 14px;
  }
}

.scroller {
  flex: 1;
  min-width: 0;
  height: 190px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  background: var(--ink-850);

  /* Publishes this box's scroll position as a named timeline. Ignored by
     browsers that do not support scroll-driven animations. */
  scroll-timeline-name: --lab-ring-scroll;
  scroll-timeline-axis: block;
}

.inner {
  padding: 16px 18px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.line {
  font-size: 14px;
  line-height: 1.6;
  color: var(--silver-300);
}

.readout {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

.ring {
  width: 96px;
  height: 96px;
  transform: rotate(-90deg); /* start the sweep at twelve o'clock */
}

/* Stacked, everything has to fit the stage's fixed height, so both the
   scroll box and the ring come down a size. */
@container stage (max-width: 420px) {
  .scroller {
    width: 100%;
    flex: none;
    height: 92px;
  }
  .inner {
    padding: 12px 14px;
    gap: 10px;
  }
  .line {
    font-size: 13px;
  }
  .ring {
    width: 56px;
    height: 56px;
  }
  .readout {
    flex-direction: row;
    align-items: center;
    gap: 12px;
  }
  .pct {
    flex-direction: row;
    align-items: baseline;
    gap: 8px;
  }
}

.track,
.value {
  fill: none;
  stroke-width: 7;
  stroke-linecap: round;
}

.track {
  stroke: var(--line);
}

.value {
  stroke: var(--accent, var(--mint));
  stroke-dasharray: 1;
  /* The fallback path: JavaScript writes --progress and this reads it. In a
     browser with scroll-driven animations the animation below wins, because
     animations outrank normal declarations in the cascade. */
  stroke-dashoffset: calc(1 - var(--progress, 0));
}

@keyframes ringFill {
  from {
    stroke-dashoffset: 1;
  }
  to {
    stroke-dashoffset: 0;
  }
}

@supports (animation-timeline: scroll()) {
  .value {
    animation-name: ringFill;
    animation-duration: auto; /* meaningless on a scroll timeline */
    animation-timing-function: linear;
    animation-fill-mode: both;
    animation-timeline: --lab-ring-scroll;
  }
}

.pct {
  font-family: var(--mono);
  font-size: 15px;
  color: var(--silver-050);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}

.mode {
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--silver-500);
}

Questions worth answering

Does it work in Safari?

Chrome and Edge 115+ run this natively. Safari and Firefox do not support animation-timeline yet, so they get the scroll-listener fallback, which produces the same ring. The demo above tells you which one you are looking at.

Does it need JavaScript?

Not in a browser that supports scroll-driven animations: there, the technique is pure CSS. The fallback for everything else is about ten lines, and it is the only reason this demo ships any script at all.

Is it accessible?

Reduced motion. Scroll-driven animation is not unrequested motion: it only advances when the visitor scrolls, and it stops when they stop, so the ring is left running. What is disabled is the entrance transition on the panel around it.

Keyboard. The scroll box is focusable and scrollable with the arrow keys, which browsers do not always give you for free on an overflow container. The ring itself is decorative and aria-hidden; the percentage it represents is also rendered as text, so nothing is conveyed by the graphic alone.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.