Native CSS Scroll Progress Ring — No JavaScript

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

Share & Support

What's included

Features

Zero JavaScript scroll handling — pure animation-timeline: scroll(root)
SVG stroke-dasharray / stroke-dashoffset ring-fill technique
Discrete percentage label driven by the same scroll timeline
Fixed corner placement that never competes with a sticky header
@supports not (animation-timeline: scroll()) graceful fallback
Compositor-driven — smooth even during fast or inertial scrolling
No dependency on a specific article length — timeline is scroll(root)
Easily swapped for scroll(nearest) to track a scrollable container

About this UI Snippet

Native CSS Scroll Progress Ring — animation-timeline: scroll() on an SVG Circle

Screenshot of the Native CSS Scroll Progress Ring snippet rendered live

A scroll progress ring is a small circular indicator, usually pinned to a corner of the viewport, that fills up as the reader moves through a page. It is a more compact alternative to a full-width top bar (see CSS Scroll-Driven Progress Bar) and works especially well alongside a header that already owns the top strip of the screen. This version is built with zero JavaScript scroll handling — the entire sweep runs off the native CSS Scroll-Driven Animations API.

The SVG circle trick

The ring is a standard SVG <circle> with stroke-dasharray set to its own circumference (2 * PI * r, here 169.6 for a radius of 27) and stroke-dashoffset starting at that same value — which visually hides the entire stroke. Animating stroke-dashoffset down to 0 reveals the stroke progressively around the circle, exactly the same trick used by countless JavaScript-driven progress rings, except here nothing but a @keyframes block and animation-timeline moves the number.

animation-timeline: scroll(root)

Rather than binding the spr-sweep keyframe animation to a duration in seconds, animation-timeline: scroll(root) binds its 0%–100% playback position directly to the document's scroll position — 0% is the top of the page, 100% is the bottom. There is no requestAnimationFrame loop, no scroll event listener, and no manual getBoundingClientRect math; the browser's compositor updates the stroke on every frame of scroll, even scroll driven by inertia after the user's finger leaves a trackpad.

Faking a numeric readout with content steps

Real numeric interpolation isn't yet exposed to plain CSS custom properties without @property, so the percentage label here uses a discrete-step trick: a second animation-timeline-bound keyframe animation swaps the content of a ::after pseudo-element across ten evenly spaced percentage strings ("10%", "20%", and so on) using animation-timeline-range steps rather than a smooth linear easing. It is coarse compared to a JS-driven counter, but it is enough for a glanceable readout and it costs zero script.

Comparing to the top progress bar

CSS Scroll-Driven Progress Bar and Scroll Timeline Nav Progress Indicator both apply the same animation-timeline: scroll(root) primitive to a horizontal bar's transform: scaleX(). This snippet proves the same primitive generalizes to any animatable property, including an SVG stroke offset — the timeline itself does not care what it is driving.

Browser support and the fallback

Chromium-based browsers (Chrome, Edge, Opera, Brave, and Chromium-based Arc) support animation-timeline: scroll() as of recent versions. Firefox and Safari support is still landing behind ongoing standards work, so this snippet wraps the animation in @supports not (animation-timeline: scroll()), which resets stroke-dashoffset to a static value and swaps the label to an em dash rather than leaving the ring stuck at 0% or fully invisible in unsupported browsers.

Customizing it

Change the circle's r and recompute stroke-dasharray to resize the ring, swap scroll(root) for scroll(nearest) to track a scrollable panel instead of the whole document, or replace the color with a CSS gradient using stroke on a linear gradient defined via <defs> for a more branded look. Pair it with a Scroll-to-Top Button fixed in the same corner once the ring nears 100%.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the stroke-dasharray math or the scroll-timeline wiring by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why stroke-dasharray is set to the circle's circumference and why animating stroke-dashoffset toward 0 reveals more of the stroke, and how animation-timeline: scroll(root) replaces what would otherwise be a scroll event listener and a getBoundingClientRect calculation. The same assistant can help you extend it — ask it to make the numeric label count up smoothly using a registered @property with a numeric syntax instead of the discrete content-swap trick, add a color transition from one hue to another as the ring fills, or wire the ring to scroll(nearest) so it tracks a scrollable sidebar instead of the whole document. 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 fixed in a page corner using only the native CSS animation-timeline: scroll() API — no JavaScript scroll event listeners, no requestAnimationFrame loop.

Requirements:
- An SVG circle used as a progress ring: a static background track circle plus a foreground fill circle whose stroke-dasharray equals its own computed circumference.
- A single @keyframes animation on the fill circle that animates stroke-dashoffset from the full circumference down to 0, bound via animation-timeline: scroll(root) so its 0%-to-100% playback position tracks the document's scroll position directly, with no duration in seconds.
- A small numeric percentage label rendered near the center of the ring that also updates as the page scrolls, without using JavaScript to read scroll position — driven by the same or a second scroll(root) timeline.
- Wrap the animation in an @supports not (animation-timeline: scroll()) block that provides a sane static fallback (a fully visible ring, not one stuck invisible or at 0%) for browsers that do not yet support the feature.
- Keep any JavaScript limited to a one-time CSS.supports('animation-timeline: scroll()') feature check for a console message or fallback label text — 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.

Step by step

How to Use

  1. 1
    Paste the HTML, CSS, and JSThe ring, label, and a long scrollable article render immediately.
  2. 2
    Scroll the page slowlyWatch the ring's stroke sweep and the percentage label step upward.
  3. 3
    Resize the ringChange the SVG circle's r attribute and recompute stroke-dasharray to match its new circumference.
  4. 4
    Change the colorEdit stroke on .spr-fill, or swap it for a gradient stroke via an SVG <linearGradient>.
  5. 5
    Track a panel instead of the pageSwap scroll(root) for scroll(nearest) and apply overflow: auto to the ring's positioned ancestor.
  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 and blog readers
Pair with a Table of Contents so readers see both where they are in the document and which section they are near.
Docs and knowledge-base pages
A corner ring keeps progress visible without stealing header space already used by a Sticky Header.
Learn animation-timeline fundamentals
A minimal, single-purpose demo of binding a keyframe animation to scroll(root) before combining it with more complex view() timelines elsewhere in this library.
Landing pages with a long story scroll
Give visitors a persistent sense of how much content remains below the fold on a marketing or portfolio page.
Replace a JS scroll-progress plugin
Removes the need for scroll-event-based progress libraries for this specific effect — the browser now does the math.
Related: CSS Scroll-Driven Progress Bar
See the CSS Scroll-Driven Progress Bar for the same technique applied to a top-of-page bar.
Related: Sticky Section Counter (scroll-timeline)
See the Sticky Section Counter (scroll-timeline) for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

No. The ring's sweep and the percentage label are both entirely driven by CSS animation-timeline: scroll(root). The included JavaScript only logs whether the browser supports the feature and swaps the label to an em dash if it does not — it never reads scroll position or drives the animation.

It is a standard SVG circle with stroke-dasharray set to its own circumference and stroke-dashoffset animated from that same value down to 0 via a @keyframes block. Revealing stroke-dashoffset progressively reveals more of the ring's stroke — the classic SVG progress-ring technique, just driven by a scroll timeline instead of a duration.

Plain CSS content on a pseudo-element cannot interpolate arbitrary numeric strings smoothly without registering a custom @property, so this snippet swaps between ten fixed percentage strings across the scroll timeline. It is a deliberate simplicity trade-off — swap in a small JS ResizeObserver-free scroll listener only if you need smooth digit counting.

The @supports not (animation-timeline: scroll()) block resets the ring to a static stroke-dashoffset: 0 (a full, non-animating ring) and swaps the label to an em dash, so Firefox and Safari users see a stable static indicator rather than one stuck at 0% or a broken animation.

Yes. Change animation-timeline: scroll(root) to animation-timeline: scroll(nearest) and make sure the ring's positioned ancestor is the scrollable element with overflow: auto — the timeline then tracks that container's scroll range instead of the document's.

The ring is marked aria-hidden since it is a supplementary visual indicator, not primary content. If it conveys information not available elsewhere, expose an equivalent programmatically via aria-valuenow on a role="progressbar" element updated alongside the CSS animation.