A thin reading-progress bar fixed to the top or bottom of the page, or to a scrollable panel, that fills on a soft spring (or exactly under reduced motion).
Designing calm software
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 1 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 2 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 3 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 4 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 5 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 6 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 7 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 8 of nine.
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and respond quickly enough that people never wonder whether something worked. Paragraph 9 of nine.
"use client";
import * as React from "react";
import { ScrollProgress } from "@/components/ballmac/scroll-progress";
export default function ScrollProgressDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full max-w-xl overflow-hidden rounded-xl border bg-card shadow-sm">
<ScrollProgress container={scroller} className="absolute" />
<div ref={scroller} role="region" tabIndex={0} aria-label="Article" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-72 overflow-auto px-6 py-6">
<article className="grid gap-4 text-sm leading-relaxed text-muted-foreground">
<h2 className="text-xl font-semibold tracking-tight text-foreground">Designing calm software</h2>
{Array.from({ length: 9 }, (_, i) => (
<p key={i}>
Good interfaces make the next step obvious. They keep important information in view, hide what is not needed yet, and
respond quickly enough that people never wonder whether something worked. Paragraph {i + 1} of nine.
</p>
))}
</article>
</div>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/scroll-progressInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/scroll-progress.tsx// Ballmac UI: Scroll Progress. https://ui.ballmac.com/components/scroll-progress "use client"; import * as React from "react"; import { motion, useReducedMotion, useScroll, useSpring } from "motion/react"; import { cn } from "@/lib/utils"; type ScrollProgressProps = Omit<React.ComponentProps<"div">, "children"> & { /** Which edge the bar sits on. */ position?: "top" | "bottom"; /** A scrollable element to track instead of the page. Give the bar `absolute` positioning inside its wrapper. */ container?: React.RefObject<HTMLElement | null>; /** Bar thickness in pixels. */ thickness?: number; }; /** * A thin bar that fills as the reader scrolls. It is decorative (hidden from assistive technology): screen-reader * users already have their own position cues. The fill follows a soft spring, or tracks the scroll exactly under reduced motion. */ function ScrollProgress({ position = "top", container, thickness = 3, className, ...props }: ScrollProgressProps) { const reduce = useReducedMotion(); const { scrollYProgress } = useScroll(container ? { container } : undefined); const spring = useSpring(scrollYProgress, { stiffness: 200, damping: 30, restDelta: 0.001 }); return ( <div aria-hidden="true" data-slot="scroll-progress" style={{ height: thickness }} className={cn( "pointer-events-none fixed inset-x-0 z-50 bg-transparent", position === "top" ? "top-0" : "bottom-0", className, )} {...props} > <motion.div className="h-full origin-left bg-gradient-to-r from-primary/70 to-primary" style={{ scaleX: reduce ? scrollYProgress : spring }} /> </div> ); } export { ScrollProgress, type ScrollProgressProps };Update the import paths to match your project setup.
Usage
import { ScrollProgress } from "@/components/ballmac/scroll-progress"The full example is in the Code tab above.
Examples
Bottom, thicker
A thicker bar along the bottom edge. Keep scrolling to fill it, line 1.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 2.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 3.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 4.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 5.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 6.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 7.
A thicker bar along the bottom edge. Keep scrolling to fill it, line 8.
"use client";
import * as React from "react";
import { ScrollProgress } from "@/components/ballmac/scroll-progress";
export default function ScrollProgressStates() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full max-w-sm overflow-hidden rounded-xl border bg-card">
<ScrollProgress container={scroller} position="bottom" thickness={5} className="absolute" />
<div ref={scroller} role="region" tabIndex={0} aria-label="Long text" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-44 overflow-auto p-4 pb-6 text-sm text-muted-foreground">
{Array.from({ length: 8 }, (_, i) => (
<p key={i} className="mb-3">A thicker bar along the bottom edge. Keep scrolling to fill it, line {i + 1}.</p>
))}
</div>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
positionWhich edge the bar sits on. | "top" | "bottom" | "top" |
containerA scrollable element to track instead of the page. Give the bar `absolute` positioning inside its wrapper. | React.RefObject<HTMLElement | null> | — |
thicknessBar thickness in pixels. | number | 3 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | Decorative (aria-hidden); the page's own scroll position is already exposed |
| Reduced motion | No spring smoothing |
Use with AI
Render <ScrollProgress /> once. Pass container (a ref) to follow a scrollable element and add className='absolute' inside its wrapper. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Scroll Progress (@ballmac/scroll-progress) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Long articles and docs
- Multi-section landing pages
Not for
- Task or upload progress; use progress
- Step indicators; use progress-steps
Registry JSON: https://ui.ballmac.com/r/scroll-progress.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Table Of Contents
An 'On this page' list with a scroll spy and a sliding current-section marker. It collects headings itself or takes a list, and scrolls below sticky headers.
Back To Top
A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Breadcrumb
A compact navigation trail with truncation, semantic current page, and visible keyboard focus.