tool / scroll progresscss · javascript · react

Scroll progress bar generator

A reading progress bar that fills as the page scrolls. Pick the edge, the thickness and whether it follows the whole page or one article, scroll the preview, then copy it as pure CSS, or as JavaScript and React that work where CSS scroll timelines do not.

Open the generator ↓
Styles
4
Follows
2
Outputs
3
Licence
MIT
progress = scrollY ÷ (scrollHeight − innerHeight)scrollHeightmax scrollYinnerHeightscrollY 0scaleX(0)scrollY ½ maxscaleX(0.5)scrollY maxscaleX(1)
Fig. 1 — the window (solid) over the page (dashed). The bar along its top edge is how far the window has travelled over how far it can: 0 at the top, 0.5 half way, 1 at the bottom.
01The tool

Make your progress bar

style
height · 4px
track
follows
color

preview · scrollProgressthe box stands in for the window

field notes · 2 min read

Reading progress, measured

A reading progress bar answers one question: how much is left? It fills as you move down the page, and it moves only when you do.

The number behind it is how far the page has scrolled, divided by how far it can scroll. At the top that is 0; at the very bottom it is 1.

Scale the bar with a transform instead of changing its width. A transform never makes the browser lay the page out again, so long pages stay smooth.

On a blog the page is more than the post: a header above it, comments and links below. Follow the article instead, and the bar is full when the last paragraph is on screen.

Where the browser has scroll-driven animations, CSS can do all of it with animation-timeline. Everywhere else, scrollProgress writes the same number into a CSS variable.

Keep it thin and out of the way, and hide it from screen readers: it repeats what the scrollbar already says.

That is the whole trick. Scroll back up and it empties again.

--scroll-progress 0.000scrollTop 0

scrollProgress writes the progress into --scroll-progress and the CSS fills the bar from it. Works where scroll-driven animations do not.

index.html2 lines
<!-- Anywhere in <body>: it is fixed to the window. -->
<div class="progress-bar" aria-hidden="true"></div>
progress.css12 lines
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  height: 4px;
  background: currentColor;
  transform-origin: 0 50%;
  transform: scaleX(var(--scroll-progress, 0));
  pointer-events: none;
}
main.js8 lines
import { scrollProgress } from 'svg-scroll-draw/progress';

// 0 at the top of the page, 1 at the bottom, written to
// --scroll-progress on <html>; the bar’s CSS reads it.
scrollProgress(document.documentElement, {
  trigger: { start: 'top top', end: 'bottom bottom' },
  easedVariable: null, // the bar only reads the raw value
});
02The model

How scroll becomes a bar

The bar has one number behind it, from 0 to 1: how far the window has travelled down the page, over how far it can travel (fig. 1). That is 0 with the page’s top at the window’s top and 1 with its bottom at the window’s bottom, which is the trigger 'top top' to 'bottom bottom'. scrollProgress writes it into --scroll-progress on the element it measures.

in-effect.js9 lines
// What scrollProgress(document.documentElement, {
//   trigger: { start: 'top top', end: 'bottom bottom' },
// }) works out on every frame, in effect:
const page = document.documentElement.getBoundingClientRect().height;
const start = 0;                    // the page's top at the window's top
const end = page - innerHeight;     // the page's bottom at the window's bottom
const progress = Math.min(1, Math.max(0, (scrollY - start) / (end - start)));

document.documentElement.style.setProperty('--scroll-progress', String(progress));

The bar is full width from the start and the variable scales it. A transform never makes the browser redo layout, where changing width would on every frame.

progress.css4 lines
.progress-bar {
  transform-origin: 0 50%;       /* grow from the left edge */
  transform: scaleX(var(--scroll-progress, 0));
}
One article
Measure the article instead, with the same trigger: 0 when its top reaches the top of the window, 1 when its bottom reaches the bottom. The variable is written on the article, so the bar goes inside it to inherit it, and stays fixed to the window from there.
Short articles
The article has to be taller than the window. A shorter one has its bottom reach the bottom of the window before its top reaches the top, so the trigger runs backwards: scrollProgress holds the bar full until the whole article is in view, then empties it as the article moves up. The CSS view timeline fills it only while the whole article is in view.
html height
If a stylesheet sets html { height: 100% }, the page’s box is only as tall as the window and scrollProgress keeps the bar at 0. Measure the element that wraps your content instead. The CSS-only version reads the scroll position itself and is not affected.
03animation-timeline

CSS only: animation-timeline: scroll()

Browsers with scroll-driven animations can do all of it in CSS. scroll(root block) is the page’s own scroll position as an animation timeline; animation-duration: auto stretches the keyframes over the whole of it, and animation-fill-mode: both holds 0 before and 1 after. This is what the CSS only tab writes for a bar along the top:

progress.css26 lines
/* No bar where the browser has no scroll-driven animations:
   the empty element takes no space and shows nothing. */
@supports (animation-timeline: scroll()) {
  .progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    height: 4px;
    background: currentColor;
    transform-origin: 0 50%;
    transform: scaleX(0); /* empty while there is nothing to scroll */
    animation-name: progress-grow;
    animation-duration: auto;
    animation-timing-function: linear;
    animation-fill-mode: both;
    animation-timeline: scroll(root block);
    pointer-events: none;
  }
}

@keyframes progress-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}
The fallback
A browser without scroll timelines ignores animation-timeline and plays the keyframes as an ordinary animation of no length, which leaves the bar full. The @supports block keeps it hidden there instead; the JavaScript version is the one that works in those browsers.
Nothing to scroll
On a page shorter than the window, scroll(root) has no distance to measure: the timeline is inactive and the animation applies nothing, so a bar with no transform of its own would show full. The generated CSS gives it the first keyframe as its own style, and it stays empty there.
The shorthand
animation resets animation-timeline, so a shorthand written after the timeline undoes it. Longhands, as here, avoid the trap.
NeedCSS onlyscrollProgress
JavaScriptNoneOne property write a frame while the target is on screen; the whole page always is
BrowsersChrome and Edge 115+, Safari 26+, Firefox 159+Chrome and Edge 80+, Safari 14+, Firefox 75+: the library’s own floor
One articleA named view-timeline on it, animation-range: containTarget it, trigger 'top top' → 'bottom bottom'
A scrolling panelA named scroll-timeline on itThe scrollContainer option
A callbackNoneonProgress(raw, eased)
04Client component

React and Next.js

The React tab writes a client component that starts scrollProgress in an effect and destroys it in the cleanup. It never sets state: the bar is CSS reading the variable, so the component renders once and scrolling costs React nothing.

svg-scroll-draw/react also has a hook, useScrollDrawProgress, that returns the progress as a number. It suits a value you need in React, such as a percentage label, but it re-renders the component as the value changes, and for readers who ask for reduced motion it returns 1 at once, which for a reading bar would mean a full bar. The effect follows the scroll for everyone.

ReadingPercent.tsx11 lines
'use client';

import { useScrollDrawProgress } from 'svg-scroll-draw/react';

/** The progress as a number in React, for a label rather than the bar. */
export function ReadingPercent() {
  const progress = useScrollDrawProgress('html', {
    trigger: { start: 'top top', end: 'bottom bottom' },
  });
  return <p className="reading-percent">{Math.round(progress * 100)}% read</p>;
}
Next.js
Mark the file 'use client' and render it once in app/layout.tsx. The effect only runs in the browser, and scrollProgress returns an inert instance where there is no window, so server rendering never reaches the DOM.
058 options

Every scrollProgress option

variable

'--scroll-progress' by default. The custom property the raw progress, 0 to 1, is written to, on the target element. Everything inside the element inherits it.

easedVariable

'--scroll-progress-eased' by default. A second property holding the eased value. null skips it, which the generator does: a bar only reads the raw one.

trigger

{ start: 'top bottom', end: 'bottom top' } by default. Where progress is 0 and where it is 1, as element edge then window edge. The default runs while any part of the element is on screen; a reading bar wants 'top top' to 'bottom bottom'.

easing

'linear' by default. Applies to the eased value only: 'ease-in', 'ease-out', 'ease-in-out', 'spring', 'bounce', 'elastic', or a function of t.

speed

1 by default. Multiplies the progress before it is held between 0 and 1, so 2 fills the bar over the first half of the scroll.

axis

'y' by default. 'x' follows horizontal scrolling instead.

scrollContainer

the window by default. An element, or a selector, that scrolls instead of the window: a modal, a side panel, the box in the preview above.

onProgress

none by default. Called with the raw and eased values when it starts and on every frame while the element is on screen.

panel.js8 lines
import { scrollProgress } from 'svg-scroll-draw/progress';

// A panel that scrolls on its own: measure what is inside it, against it.
scrollProgress('.panel-content', {
  scrollContainer: '.panel',
  trigger: { start: 'top top', end: 'bottom bottom' },
  easedVariable: null,
});
The instance
scrollProgress returns an instance: destroy() stops it and removes both properties, pause() and resume() stop and restart it, seek(p) writes a value and pauses, and replay() measures the element again. It also measures again by itself when the window resizes or something in or above the element changes size, such as an image loading.
067 questions

Scroll progress bar questions

Q1

How do I make a scroll progress bar with CSS only?

Give a fixed bar a CSS animation from scaleX(0) to scaleX(1), and set animation-timeline: scroll(root block) with animation-duration: auto and animation-fill-mode: both. The page’s scroll position then drives it with no JavaScript. Chrome and Edge 115+, Safari 26+ and Firefox 159+ support it. Give the bar transform: scaleX(0) as its own style too: on a page too short to scroll the timeline is inactive, the animation applies nothing, and the bar would show full. Wrap it all in @supports (animation-timeline: scroll()): a browser that ignores the timeline runs the keyframes as an ordinary animation of no length and also leaves the bar full.

Q2

How do I add a reading progress bar in React or Next.js?

Write a client component that calls scrollProgress(document.documentElement, { trigger: { start: 'top top', end: 'bottom bottom' } }) in a useEffect and returns progress.destroy() as its cleanup, and renders a fixed div whose CSS is transform: scaleX(var(--scroll-progress, 0)). In the Next.js App Router, mark the file 'use client' and render it once in app/layout.tsx. The React tab of the generator writes the whole file.

Q3

How do I show reading progress for one article, not the whole page?

Measure the article instead of the page: scrollProgress('article', { trigger: { start: 'top top', end: 'bottom bottom' } }) is 0 when the article’s top reaches the top of the window and 1 when its bottom reaches the bottom of it. Put the bar inside the article, so it inherits the --scroll-progress written there. In CSS only, give the article view-timeline-name: --article and the bar inside it animation-timeline: --article with animation-range: contain 0% contain 100%. Either way the article has to be taller than the window.

Q4

Why scale the bar with transform instead of changing its width?

Changing width makes the browser redo layout on every frame of scrolling. A transform scales a bar that is already full width, with no layout at all, so it stays smooth on long pages, and in the CSS-only version the browser can run it off the main thread.

Q5

Can I make a circular scroll progress indicator?

Yes: an SVG circle whose stroke-dasharray is its circumference, 2πr, and whose stroke-dashoffset goes from the circumference to 0 as you scroll, calc(C * (1 - var(--scroll-progress))). Rotate it -90° so it starts at twelve o’clock. Pick ring in the generator and it works out the numbers for your stroke.

Q6

How do I track scroll progress inside a scrolling div?

Pass the div as scrollContainer and measure the element inside it that holds the content: scrollProgress('.panel-content', { scrollContainer: '.panel', trigger: { start: 'top top', end: 'bottom bottom' } }). Put the bar first inside .panel-content with position: sticky and top: 0, not fixed, so it inherits the variable and stays at the top of the panel. In CSS only, name the div’s timeline with scroll-timeline-name and set the bar’s animation-timeline to that name; keep the bar inside the div, since Chrome lets only an element’s descendants use the timeline it names.

Q7

Should a reading progress bar be hidden from screen readers?

Yes. It repeats what the scrollbar already shows and has nothing useful to say aloud, so the generated markup marks it aria-hidden="true". It moves only while the reader scrolls, so it is never motion they did not start.

scrollProgress in svg-scroll-draw 2.23.3: the docs. More tools: the text animation generator and the SVG path viewer.