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)
Native CSS counter percentage readout, stepped with steps(20) for clean whole-number jumps
Ring and counter 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 counter 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 CSS counter 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 counter, not a smoothly interpolated number

CSS counters hold whole numbers and cannot fractionally interpolate, so the percentage readout uses the same multi-stop steps() technique as CSS Scroll Timeline Section Counter: twenty-one keyframe stops, five percentage points apart, each incrementing the counter by 5, wrapped in steps(20) so the digits jump cleanly rather than trying to blend between whole numbers.

One shared timeline, two properties

Both the ring's stroke and the counter'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 counter 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 counter needs twenty-one explicit keyframe stops instead of 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 counter 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 from a native CSS counter (counter-reset / counter-increment / content: counter()) on a pseudo-element, animated with a steps() timing function across at least fifteen to twenty explicit keyframe stops evenly spaced across the 0%-100% range, each incrementing the counter by a fixed whole-number amount — not a single smooth keyframe, since CSS counters cannot fractionally interpolate.
- Both the ring's stroke-dashoffset animation and the counter'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 counter 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 is a native CSS counter, stepping upward in fixed increments 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 counter 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 counter granularityAdd more 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.

CSS counters are whole numbers and are not 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.

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 counter value.

The @supports not (animation-timeline: scroll()) block fixes the ring at a static 75% fill and the counter at a static "75" label, so Firefox and Safari users see an intentional, complete-looking state rather than a ring and counter 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.