CSS Scroll Timeline Progress Ring — Native animation-timeline: scroll()

CSS Scroll Timeline Progress Ring · Scroll · Plain HTML, CSS & JS · Live preview

What's included

Features

SVG stroke-dashoffset unwind driven entirely by animation-timeline: scroll(root)
Percentage readout steps through literal content values per keyframe, stepped with steps(20) for clean whole-number jumps
Ring and readout share one scroll timeline, so they never drift out of sync
Sticky positioned stage keeps the ring pinned while content scrolls past beside it
Zero JavaScript scroll listeners or requestAnimationFrame loops
drop-shadow glow on the fill stroke for a polished at-rest and in-motion look
@supports fallback locks to a static 75% ring instead of a stuck-at-zero state
Fully reversible — scrolling up rewinds both the ring and the readout in lockstep

About this UI Snippet

CSS Scroll Timeline Progress Ring — SVG stroke-dashoffset Driven by scroll(root)

Screenshot of the CSS Scroll Timeline Progress Ring snippet rendered live

A circular reading-progress indicator is normally built with a scroll event listener computing a percentage and writing it into an inline style every frame. This snippet replaces that entirely with native CSS: an SVG ring whose stroke-dashoffset and a percentage readout whose digits both advance purely because they are bound to animation-timeline: scroll(root).

Why stroke-dashoffset instead of a conic-gradient

An SVG circle's circumference is a fixed, known number (2 * PI * r, here 534.07 for a radius of 85). Setting stroke-dasharray to that exact circumference and animating stroke-dashoffset from the full circumference down to 0 unwinds the visible stroke smoothly, because stroke-dashoffset is a natively interpolable SVG presentation property — no custom @property registration is required, unlike animating a conic-gradient()'s angle directly.

A stepped readout, and why it isn't a CSS counter

The percentage digits animate via twenty-one keyframe stops, five percentage points apart, each setting content to an absolute literal value ("0", "5", "10" ... "100") on a pseudo-element, wrapped in steps(20) so the digits jump cleanly between whole numbers. The more obvious-looking approach — counter-reset plus counter-increment: 5 at every stop — looks identical on paper but breaks under a scroll-scrubbed timeline: counter-increment is a delta added to whatever total already existed, and that only accumulates correctly when an animation plays through its keyframes in order, over real time. A scroll(root)-bound animation instead jumps directly to whichever keyframe matches the current scroll position without passing through the others, so only that one keyframe's increment would ever apply — the readout would freeze at 5 no matter how far down the page you scrolled. Writing the absolute value straight into content sidesteps the problem, since there's no running total to lose.

One shared timeline, two properties

Both the ring's stroke and the readout's digits reference the exact same animation-timeline: scroll(root) — the whole document's scroll range — so they are guaranteed to stay in lockstep with each other and with the page, with no coordination code linking them beyond both being bound to the same timeline name.

Browser support

Chromium-based browsers (Chrome, Edge, Opera, Brave) support animation-timeline: scroll() today; Firefox and Safari support is still landing. The @supports not (animation-timeline: scroll()) block freezes the ring at a static 75% fill and the counter at a static "75" rather than leaving either stuck at 0.

Customizing it

Change the circle's r attribute and recompute the matching stroke-dasharray circumference for a bigger or smaller ring, swap stroke-linecap: round for butt for a flat-ended fill, or replace the SVG entirely with a conic-gradient mask if you would rather avoid inline SVG. Pair it with CSS Scroll Timeline Gauge Needle for a related dial-style scroll readout.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the stroke-dashoffset math or the stepped readout by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the circle's circumference determines the stroke-dasharray value, and why the percentage readout needs twenty-one explicit keyframe stops with literal content values instead of a CSS counter or a single smooth 0-to-100 keyframe. The same assistant is useful for extending the effect: ask it to add a second, thinner ring showing a different metric on an independent view() timeline, animate the ring's stroke color alongside the fill, or generate the keyframe stops programmatically for a different step granularity. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a circular scroll-progress ring with a synced percentage readout using only native CSS animation-timeline: scroll(root) — no JavaScript scroll listener, no requestAnimationFrame loop.

Requirements:
- An inline SVG circle used as a progress ring: a static background track circle plus a foreground fill circle with stroke-dasharray set to its exact circumference (2 * PI * r) and stroke-dashoffset animated from that same circumference down to 0 via a @keyframes animation bound to animation-timeline: scroll(root).
- A percentage readout built by animating a pseudo-element's content property directly, with a steps() timing function across at least fifteen to twenty explicit keyframe stops evenly spaced across the 0%-100% range, each setting content to a literal absolute string value (e.g. content: "45") rather than using counter-reset/counter-increment. A CSS counter's increment is a delta that only accumulates correctly under real sequential time-based playback, and silently fails to reach later values when driven by a scroll-scrubbed, directly-seekable timeline — writing the absolute number straight into content at each step avoids that failure mode entirely.
- Both the ring's stroke-dashoffset animation and the readout's animation must reference the same animation-timeline: scroll(root) so they always agree at any given scroll position.
- A sticky-positioned stage that pins the ring in the viewport while several full-height content sections scroll past beside it, giving the scroll(root) timeline enough range to animate across.
- Add an @supports not (animation-timeline: scroll()) fallback that fixes the ring at a reasonable static partial fill and the readout at a matching static number, rather than leaving either stuck at zero in unsupported browsers.
- Keep any JavaScript limited to a one-time CSS.supports('animation-timeline: scroll()') feature check logged to the console — it must never drive or read scroll position itself.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

<div class="ptr-page">
  <aside class="ptr-stage">
    <p class="ptr-hint">Scroll ↓ — the ring fills with the page</p>
    <div class="ptr-ring-wrap">
      <svg class="ptr-svg" viewBox="0 0 200 200" aria-hidden="true">
        <circle class="ptr-track" cx="100" cy="100" r="85"></circle>
        <circle class="ptr-fill" cx="100" cy="100" r="85"></circle>
      </svg>
      <div class="ptr-readout"><span class="ptr-num"></span><span class="ptr-sign">%</span></div>
    </div>
  </aside>
  <main class="ptr-sections">
    <section class="ptr-block"><span class="ptr-tag">Start</span><h2>A Ring Bound to the Page, Not a Timer</h2><p>The stroke on that circle is not animated by a duration or a JavaScript scroll listener — it is bound directly to how far down this document you have scrolled, via native CSS <code>animation-timeline: scroll(root)</code>.</p></section>
    <section class="ptr-block"><span class="ptr-tag">Building</span><h2>Keep Scrolling</h2><p>The stroke-dashoffset on the fill circle unwinds continuously as the scroll position advances — no threshold checks, no requestAnimationFrame loop.</p></section>
    <section class="ptr-block"><span class="ptr-tag">Almost There</span><h2>Nearly Full</h2><p>The percentage readout beside the ring steps upward through fixed keyframe values as the same scroll timeline advances.</p></section>
    <section class="ptr-block"><span class="ptr-tag">Done</span><h2>100%</h2><p>By the bottom of the page the ring is completely filled — scroll back up and watch every part of it rewind in perfect sync.</p></section>
  </main>
</div>

Step by step

How to Use

  1. 1
    Paste the HTML, CSS, and JSA sticky ring and percentage readout render beside four full-height content blocks.
  2. 2
    Scroll from top to bottomWatch the ring stroke unwind and the percentage climb in sync, purely via animation-timeline: scroll(root).
  3. 3
    Scroll back upBoth the ring and the readout rewind exactly, since they read a live scroll timeline, not a one-shot trigger.
  4. 4
    Resize the ringChange the SVG circle radius and recompute stroke-dasharray to 2 * PI * r for a different ring size.
  5. 5
    Adjust the readout granularityAdd more content: "N" keyframe stops to ptr-count-up (and a matching steps() value) for finer-grained percentage jumps.
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.

Real-world uses

Common Use Cases

Long-form article reading progress
A circular alternative to a top-of-page progress bar like Scroll Progress.
Portfolio case-study pages
Show how far through a scrollytelling case study the visitor has traveled.
Learn scroll-driven SVG animation
A focused demo of animating stroke-dashoffset with a native scroll timeline instead of JavaScript.
Onboarding or checkout step trackers
Pair with a fixed set of steps to show overall completion as the user scrolls through them.
Replace a scroll-listener progress ring
Removes the need for a scroll-event-driven percentage calculation for this specific ring UI.
Related: CSS Scroll Timeline Gauge Needle
See CSS Scroll Timeline Gauge Needle for a related dial-style scroll readout.
Related: Scroll Progress (circle)
See Scroll Progress for a JS-driven progress indicator worth comparing against this native version.

Got questions?

Frequently Asked Questions

An SVG circle’s stroke-dashoffset is a natively interpolable presentation property, so the browser can animate it directly with keyframes and a scroll timeline. Animating a conic-gradient’s angle smoothly requires registering the custom property via @property first; stroke-dashoffset needs none of that setup.

Discrete string content values cannot be fractionally interpolated between keyframes. Using twenty-one keyframe stops five percentage points apart with steps(20) keeps the displayed number always a clean integer without needing a hundred individual keyframe stops.

A CSS counter animated via counter-increment only accumulates correctly when an animation plays through its keyframes in sequence over real time — each step adds its delta to whatever total already existed. A scroll-linked timeline instead jumps directly to whichever keyframe matches the current scroll position without passing through the others, so only that one keyframe’s increment would ever apply, and the readout would freeze after the first step regardless of how far down the page you scrolled. Writing the absolute value straight into content at each step has no running total to lose, so it stays correct at any scroll position.

No, because both are bound to the exact same animation-timeline: scroll(root) — the whole document’s scroll range — so any given scroll position always maps to the same ring fill and the same readout value.

The @supports not (animation-timeline: scroll()) block fixes the ring at a static 75% fill and the readout at a static "75" label, so Firefox and Safari users see an intentional, complete-looking state rather than a ring and readout stuck at zero.

Yes — animation-timeline: scroll(root) already measures the entire document’s scroll range by default, so as long as this markup is the only scrollable content on the page, the ring already reflects true whole-page progress; nesting it inside a shorter scroll container would require scroll(nearest) instead.