Scroll Progress Bar
A hairline on the top edge tracks how far through a long article the reader has come.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, type UIEvent } from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from "motion/react";
/**
* Vibary · Scroll Progress Bar
*
* A hairline across the top edge that reports how far through the
* article the reader is. The scroll position drives it directly — the
* bar is a projection of the scroll, never a clip that plays.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `accent`.
* Scroll the article — or let it demonstrate itself on mount.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ScrollProgressBarProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Bar color. A brand accent, so it stays literal in both themes. */
accent?: string;
/**
* Scrolls the article once on mount so the bar fills without input.
* Turn this off in a real app — there the reader is the trigger.
*/
demoScroll?: boolean;
};
type VariantConfig = {
/** Bar thickness in px. */
thickness: number;
/** Smoothing applied to the raw scroll fraction. */
spring: { stiffness: number; damping: number; restDelta: number };
};
// Quality rule: this is the one place a spring is not decoration. Raw
// scroll input is jittery on a trackpad and stepped on a wheel, so the
// fraction is passed through a spring to smooth it — which means the
// spring has to be over-damped or the bar would rock past the reader's
// actual position and back. Every setting here sits at or above a 0.8
// damping ratio. Variants change thickness and how much lag the
// smoothing introduces, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Thin and near-instant. For a documentation page where the bar should
// be information and nothing else.
subtle: {
thickness: 2,
spring: { stiffness: 490, damping: 50, restDelta: 0.0008 },
},
// A visible rule that trails the scroll by a hair. All-purpose.
default: {
thickness: 3,
spring: { stiffness: 240, damping: 32, restDelta: 0.001 },
},
// Thicker with more lag, so the bar reads as a moving object — for a
// long-form editorial page.
playful: {
thickness: 5,
spring: { stiffness: 120, damping: 21, restDelta: 0.0012 },
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or track that is
* correctly toned in either theme. The bar's accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ARTICLE = [
"Every team that ships weekly ends up writing this document. It starts as a list of what broke, and becomes the description of how the work is actually done.",
"The first section is always the deploy. Who can run it, what it checks, and what happens when a check fails at four in the afternoon on a Friday.",
"The second is ownership. Not a directory of names, but the smaller question underneath it: when a page is slow, whose week does that become?",
"Then the part nobody drafts in advance — the record of decisions that were obvious at the time and unexplainable six months later.",
"Keep it short enough that someone reads it on their first day, and specific enough that it is still true on their second week.",
] as const;
export default function ScrollProgressBar({
variant = "default",
accent = ACCENT,
demoScroll = true,
}: ScrollProgressBarProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// 0 at the top of the article, 1 when its last line is on screen. A
// motion value rather than state: the fraction changes on every scroll
// frame, and re-rendering the tree that often would cost more than the
// animation itself.
const progress = useMotionValue(0);
const smoothed = useSpring(progress, cfg.spring);
// Reduced motion: the bar still reports the position exactly, it just
// stops easing toward it. The information is identical; only the lag
// is gone.
const scaleX = reduceMotion ? progress : smoothed;
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
// Measured per event rather than cached: the article's height changes
// when the panel is resized or a font finally lands, and a stale
// denominator makes the bar quietly lie for the rest of the session.
const distance = element.scrollHeight - element.clientHeight;
progress.set(distance > 0 ? element.scrollTop / distance : 0);
};
// The preview has no one to scroll it, so the article scrolls itself
// once and yields to the first real wheel or touch.
useEffect(() => {
const element = scrollRef.current;
if (!element || !demoScroll) return;
const distance = element.scrollHeight - element.clientHeight;
if (distance <= 0) return;
if (reduceMotion) {
const timer = window.setTimeout(() => {
element.scrollTop = distance;
}, 240);
return () => window.clearTimeout(timer);
}
const controls = animate(0, distance, {
duration: 2.6,
delay: 0.4,
ease: "easeInOut",
onUpdate: (value) => {
element.scrollTop = value;
},
});
const stop = () => controls.stop();
element.addEventListener("wheel", stop, { passive: true });
element.addEventListener("touchstart", stop, { passive: true });
return () => {
controls.stop();
element.removeEventListener("wheel", stop);
element.removeEventListener("touchstart", stop);
};
}, [demoScroll, reduceMotion]);
return (
<div
style={{
position: "relative",
width: 336,
height: 340,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
{/* Track and bar sit on this panel's top edge rather than the
viewport's, so the pattern drops into a card unchanged. For a
whole page, make this wrapper
`position: fixed; top: 0; left: 0; right: 0; z-index: 50` and
compute the same fraction from `window.scrollY` over
`document.documentElement.scrollHeight - window.innerHeight` in a
scroll listener. */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 3,
height: cfg.thickness,
background: tone(9),
}}
>
{/* scaleX, not width: a transform is composited, so the bar can
track a fast flick without laying the panel out again on every
frame. The origin pins it to the left edge as it grows. */}
<motion.div
role="progressbar"
aria-label="Reading progress"
style={{
height: "100%",
background: accent,
transformOrigin: "0% 50%",
scaleX,
}}
/>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
height: "100%",
overflowY: "auto",
padding: `${cfg.thickness + 16}px 18px 20px`,
}}
>
<div style={{ fontSize: 11.5, opacity: 0.5, letterSpacing: "0.06em" }}>
HANDBOOK · ENGINEERING
</div>
<h2
style={{
margin: "6px 0 2px",
fontSize: 19,
fontWeight: 680,
letterSpacing: "-0.015em",
lineHeight: 1.25,
}}
>
Writing the runbook nobody wanted to write
</h2>
<div style={{ fontSize: 11.5, opacity: 0.5, marginBottom: 12 }}>
6 min read · Updated Tuesday
</div>
{ARTICLE.map((paragraph) => (
<p
key={paragraph.slice(0, 24)}
style={{
margin: "0 0 12px",
fontSize: 13,
lineHeight: 1.62,
opacity: 0.72,
}}
>
{paragraph}
</p>
))}
<div
style={{
marginTop: 4,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
fontSize: 11.5,
opacity: 0.5,
}}
>
End of section · Next: On-call rotations
</div>
</div>
</div>
);
}About this pattern
Orientation for long reading: a rule pinned to the top edge that grows with the scroll and shrinks when the reader goes back. The bar is a projection of the scroll position rather than a clip that plays, so it can never disagree with where the page actually is. It scales along one axis instead of animating its width, which keeps the whole thing on the compositor and lets it track a fast flick without re-laying out the page each frame. The one subtlety is smoothing: raw wheel input arrives in steps, so the fraction runs through an over-damped spring — enough to take the staircase out, never enough to let the bar rock past the reader's real position and come back.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
A slim rule across the top of a story that fills as the reader moves down it.
Related patterns
- Route Progress TopA top-edge bar advances during navigation and completes with a quick finish.
- Back to Top AppearA return control lifts into the corner once the reader is deep enough for it to matter.
- Breadcrumb Trail AppendGoing one level deeper slides a new crumb in from the right while the trail behind it recedes.