Scroll Progress
betaA reading-progress pill with a ring, the current section, and an expandable menu for jumping through a page.
Preview
Make room to read
A long guide is easier to explore when you know where you are. Start at the beginning, follow the sections in order, or jump straight to the part you need.
Scroll inside this panel. The ring fills as you move, and the pill follows the section in view. Open it to see all four sections.
One idea at a time
Give each section a clear heading and enough space to develop its idea. Short paragraphs give readers a natural place to pause before moving on.
Good headings help people scan a page and find their way back. Use the same names in the document and its navigation.
Leave with a next step
A useful ending gives readers a place to go. Summarize what they can do with what they have learned, then make the next action easy to find.
You have reached the last section. Keep scrolling to fill the ring, or open the menu to return to the overview.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/scroll-progress
Use it for
- Keep a reader's position visible in an article or long guide.
- Pass sections in reading order, with ids matching mounted elements.
- Track the window by default. Pass containerRef or containerId for an independent scroll surface.
- Place the indicator outside the scrolling content. Use className='absolute' inside a positioned preview wrapper.
Avoid it for
- Task completion or uploads. Use Progress for work with a known completion value.
- Short content with no useful section jumps. Keep the page simple.
- Replacing visible headings. The menu supplements the document's structure.
Props
- sections
Unique section ids and labels in reading order. An empty list shows a disabled pill.
readonly { id: string; label: string }[] · default []
- containerRef
A mounted scrolling element. Takes precedence over containerId. Omit both to track the window.
RefObject<HTMLElement | null>
- containerId
The id of a mounted scrolling element, for composition without a client ref. A missing container does not track the window.
string
- offset
Pixels below the scroll surface's top edge at which a section becomes current. Also leaves space above a jump target.
number · default 24
- disabled
Stops the menu from opening. Reading progress continues to update.
boolean · default false
- aria-label
Names the menu. The trigger also includes the current section and the percentage read.
string · default "Sections"
- className
Classes for the root, merged last. Fixed at bottom center by default. Override placement for an embedded scroll surface.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| sections | readonly { id: string; label: string }[] | [] | Unique section ids and labels in reading order. An empty list shows a disabled pill. |
| containerRef | RefObject<HTMLElement | null> | - | A mounted scrolling element. Takes precedence over containerId. Omit both to track the window. |
| containerId | string | - | The id of a mounted scrolling element, for composition without a client ref. A missing container does not track the window. |
| offset | number | 24 | Pixels below the scroll surface's top edge at which a section becomes current. Also leaves space above a jump target. |
| disabled | boolean | false | Stops the menu from opening. Reading progress continues to update. |
| aria-label | string | "Sections" | Names the menu. The trigger also includes the current section and the percentage read. |
| className | string | - | Classes for the root, merged last. Fixed at bottom center by default. Override placement for an embedded scroll surface. |
Accessibility
- The trigger exposes its expanded state, current section, and percentage read. Progress is not a live announcement on every scroll.
- Enter, Space, or Arrow Down opens the menu. Arrow keys, Home, End, and typing move between sections.
- Escape closes the menu and restores trigger focus. Clicking outside also closes it.
- The active section carries aria-current='location' and a filled row.
- Jump scrolling and transitions follow the theme's motion setting, including reduced motion.
- The caller supplies unique ids, meaningful labels, and semantic headings in the reading content.
Examples
Track a page
import { ScrollProgress } from "@/components/ui/scroll-progress";
export default function Guide() {
return (
<>
<ScrollProgress sections={[{ id: "overview", label: "Overview" }, { id: "details", label: "Details" }]} />
<section id="overview"><h2>Overview</h2><p>Start with the essentials.</p></section>
<section id="details"><h2>Details</h2><p>Explore the next steps.</p></section>
</>
);
}Track a scroll container
"use client";
import { useRef } from "react";
import { ScrollProgress } from "@/components/ui/scroll-progress";
export default function ReadingPanel() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div className="relative">
<div ref={containerRef} className="h-96 overflow-y-auto pb-20" tabIndex={0} role="region" aria-label="Guide">
<section id="start" className="min-h-96"><h2>Getting started</h2></section>
<section id="next" className="min-h-96"><h2>Next steps</h2></section>
</div>
<ScrollProgress className="absolute" containerRef={containerRef} sections={[{ id: "start", label: "Getting started" }, { id: "next", label: "Next steps" }]} />
</div>
);
}