You Might Also Like
CSS Scroll-Driven Progress Bar — Native scroll() Timeline, No JS
CSS Scroll-Driven Progress Bar · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
CSS Scroll-Driven Progress Bar — animation-timeline: scroll() Explained

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