CSS Scroll-Driven Progress Bar — Native scroll() Timeline, No JS

CSS Scroll-Driven Progress Bar · Scroll · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Zero-JS core animation
The fill itself needs no scroll listener or rAF loop at all.
Compositor-driven
Runs off the main thread, immune to JS-caused jank.
Native scroll() timeline
animation-timeline: scroll(root) maps scroll to keyframes.
Graceful @supports fallback
Unsupported browsers get a visible striped placeholder.
Support detection badge
CSS.supports() confirms the feature is actually active.
Minimal JS safety net
A scroll listener only attaches when the native API is missing.
Gradient fill
A three-stop teal-to-purple gradient bar, easy to recolor.
Framework portable
Pure CSS and a tiny script — drops into any component.

About this UI Snippet

CSS Scroll-Driven Progress Bar — animation-timeline: scroll() Explained

Screenshot of the CSS Scroll-Driven Progress Bar snippet rendered live

A scroll progress bar is one of the most common pieces of DOM math on the web — read scrollY, divide by document height, write a style, every frame. This snippet replaces all of that with a single native CSS feature: scroll-driven animations. The bar's fill is a CSS keyframe animation whose timeline is the document's own scroll position, not the clock — so it plays exactly as far as you've scrolled, with no JavaScript computing it at all.

The core mechanism

The bar has animation: sdp-grow auto linear and animation-timeline: scroll(root). Normally an animation's timeline is time — sdp-grow would just play once over a fixed duration. Setting animation-timeline to scroll(root) swaps that clock for the scroll position of the document's root scroller: 0% scrolled maps to the animation's 0% keyframe, 100% scrolled maps to 100%. The keyframes themselves just scale the bar from scaleX(0) to scaleX(1) — the browser handles mapping scroll offset to animation progress natively, off the main thread, in the compositor.

Why this beats a scroll listener

A hand-rolled version needs a scroll event handler firing potentially hundreds of times per second, each computing scrollY / (scrollHeight - innerHeight) and writing a style — main-thread work competing with everything else on the page. This snippet's CSS keyframe runs on the compositor thread and updates every rendered frame regardless of main-thread load, so the bar never stutters even during heavy JS work elsewhere on the page. It pairs naturally with other scroll techniques like Reveal on Scroll or a Scroll Reveal Grid — both could migrate part of their logic to scroll timelines as browser support matures.

Graceful degradation

Scroll-driven animations currently ship in Chromium browsers (Chrome, Edge, Opera 115+) behind full support, with Firefox and Safari still rolling it out. The CSS includes an @supports not (animation-timeline: scroll()) block that swaps the bar for a striped placeholder rather than leaving it invisible or frozen. The JS layer is deliberately thin: it only checks CSS.supports('animation-timeline: scroll()') to flip a status badge, and — only when unsupported — attaches a minimal scroll listener as a safety-net fallback. The native CSS path is always what actually drives the bar when it's available.

When to reach for this vs. GSAP ScrollTrigger

If your scroll effect needs sequencing across multiple elements, scrubbing timelines with pins, or complex easing curves, a library like GSAP's ScrollTrigger (used in Scroll Reveal Grid) is still the more capable and cross-browser tool today. But for a single, simple linear or eased progress indicator — a reading bar, a scroll-to-top ring, a section indicator — native scroll timelines remove a dependency entirely and hand the work to the browser's own rendering pipeline.

Customizing it

Swap scroll(root) for scroll(nearest) to track a scrollable container instead of the whole page, change the gradient or thickness, or add a second bar bound to a view() timeline (see View Timeline Image Reveal) that only tracks a specific section rather than the whole document.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to reverse-engineer the scroll-timeline syntax by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how animation-timeline: scroll(root) reinterprets a normal CSS keyframe animation's progress as a function of scroll position instead of elapsed time, and why that lets the browser skip the main thread entirely for this effect. The same assistant can help you extend it — asking how to bind a second timeline to a view() source so a bar only tracks a specific section's scroll range rather than the whole page, or how to add a named scroll-timeline on a custom scrollable container instead of the document root. It's also useful for the fallback story: ask it to review whether the @supports block and the JS CSS.supports() check stay in sync, or to sketch an alternative fallback using IntersectionObserver for finer-grained browsers that support neither API. 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 scroll-driven reading progress bar using only native CSS scroll-driven animations (the animation-timeline: scroll() API) — no scroll event listeners and no requestAnimationFrame loop for the core effect.

Requirements:
- A fixed, full-width bar pinned to the top of the viewport with a CSS keyframe animation that scales it from scaleX(0) to scaleX(1), with transform-origin set to the left edge.
- Set animation-timeline: scroll(root) (or an equivalent named scroll-timeline bound to the document scroller) on the bar so the keyframe's progress is driven by the page's scroll position instead of elapsed time, and set animation: <name> auto linear so duration is determined by the timeline rather than a fixed time value.
- Wrap a fallback rule in @supports not (animation-timeline: scroll()) that visually differentiates unsupported browsers (for example, a static striped pattern) instead of leaving the bar broken or invisible.
- In JavaScript, only use CSS.supports('animation-timeline: scroll()') to detect support and update a small status badge — do not use JavaScript to drive the bar's fill when the native feature is supported.
- As a safety net only, add a minimal scroll event listener that manually sets the bar's transform based on window.scrollY divided by the scrollable range, but gate this fallback so it only runs when the native scroll-timeline feature is unsupported.
- Include a long scrollable article with several sections so the effect is clearly visible across a full page scroll.

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 JSAn article with a fixed progress bar and a status badge renders — no CDN required.
  2. 2
    Scroll the articleThe bar at the very top fills proportionally to how far you've scrolled.
  3. 3
    Check the badgeIt reads "Native scroll-timeline: active" in Chromium browsers.
  4. 4
    Try it in an unsupported browserThe badge switches to a fallback message and a JS scroll listener takes over.
  5. 5
    Swap the timeline sourceChange scroll(root) to scroll(nearest) to track an inner scroll container.
  6. 6
    Adjust the gradientEdit the linear-gradient colors on .sdp-bar to match your brand.

Real-world uses

Common Use Cases

Long-form articles
Give readers a sense of position, similar to pairing with Reveal on Scroll content blocks.
Documentation sites
A lightweight progress indicator for long docs pages.
Landing pages
Combine with a Scroll Reveal Grid section for a cohesive scroll story.
Case study pages
Signal how much of a portfolio case study remains.
Newsletters and blogs
A subtle top-of-page indicator that costs nothing at runtime.
Dashboards with tall panels
Bind scroll(nearest) to a scrollable panel instead of the page.

Got questions?

Frequently Asked Questions

It replaces the animation's normal time-based clock with the scroll position of the nearest scrollable ancestor named — root meaning the document's own scroller. The keyframe animation's 0%–100% progress is then driven by how far that scroller has been scrolled, rather than by elapsed seconds, so the browser computes the mapping natively without any JavaScript.

As of this writing, Chromium-based browsers (Chrome, Edge, Opera) support animation-timeline: scroll() and view() fully. Firefox and Safari are progressively rolling out support. Always wrap the effect in an @supports not (animation-timeline: scroll()) block, as this snippet does, so unsupported browsers get a sensible fallback instead of a broken or invisible bar.

Yes, meaningfully. A scroll listener runs on the main thread and competes with layout, style recalculation, and any other JavaScript running on the page. The native scroll timeline is evaluated by the compositor, so the bar keeps updating smoothly even if the main thread is busy with unrelated work — there's no risk of a stuttering or delayed progress bar.

Yes — change scroll(root) to scroll(nearest), and make sure the bar's animation is applied to an element whose nearest scrollable ancestor is the container you want to track. You can also name a scroll container explicitly with scroll-timeline-name on the scroller and reference it by name in animation-timeline.

Since the effect is pure CSS, it ports directly — just include the .sdp-bar rules in your stylesheet or CSS module and render a single fixed div with that class in your layout component. The JS support-check and fallback listener can move into a mount effect (useEffect, onMounted, or ngAfterViewInit) if you want the status badge; the progress bar itself works without any framework code at all.