Scroll Progress
A scroll progress pill that tracks reading position and expands into a squircle menu of sections you can jump to.
Introduction
Scroll Progress
A thin indicator that reports how far the reader has moved through a page or a scroll container. It sits quietly at the edge of the viewport and fills as the content advances.
Why it exists
A sense of position on long pages
Long articles hide their own length. A progress bar gives readers a continuous cue for how much remains, without adding another block of chrome to the layout.
The signal
One value, mapped to width
The component tracks a single scalar between zero and one and maps it to the fill. Everything else (color, thickness, placement) is presentation on top of that value.
Placement
Pinned to the top by default
The indicator is fixed to the top edge so it stays visible while the content scrolls beneath it. It can also be attached to the bottom or to a specific container.
Interaction
Behavior
The bar follows scroll position directly. It should feel like a physical readout of the page rather than an animation that plays on its own.
Tracking
Progress is derived, not stored
Scroll offset divided by the total scrollable distance gives the progress value. Because it is derived on every frame, it stays correct through resizes and content changes.
Smoothing
A spring keeps the fill from stuttering
Raw scroll values can arrive in jumps. Passing the value through a spring smooths the fill so it glides toward the target instead of snapping between frames.
Performance
Animate transform, never layout
The fill is driven by scaleX with a left transform origin. Scaling avoids layout work on every scroll event and keeps the indicator smooth on long documents.
Visual System
Styling
The default look is deliberately minimal: a few pixels tall, full width, and tied to the foreground color so it reads clearly in both themes.
Thickness
Keep it thin
Two to four pixels is enough to register in peripheral vision. A heavier bar competes with the content and starts to feel like a loading state rather than a position cue.
Contrast
Lean on the foreground token
Using the foreground color keeps the bar legible without a hardcoded value, so it inverts correctly when the theme flips between light and dark.
Direction
Fill from the leading edge
A left transform origin makes the bar grow in the reading direction. For right-to-left layouts the origin flips so the motion still matches the flow of text.
Implementation
Usage
Drop the component near the top of a scrollable layout and it works with no configuration. Everything past that is optional refinement.
Mounting
One instance per scroll surface
Render a single indicator for the page. If you have an independent inner scroll area, give that region its own instance pointed at the container.
Layout
Use className for placement and color
The component owns the tracking logic; the surrounding page controls where the bar sits and how it is themed through a className.
Accessibility
Respect reduced motion
The progress bar is decorative, so it should not be the only indicator of position. When reduced motion is requested, drop the spring and track scroll directly.
Loading component source...
Installation
pnpm dlx shadcn@latest add mox/mox-ui/scroll-progressRun this command from your project directory to add the component and its dependencies.
- 1
Install the packages
pnpm add motion clsx tailwind-merge - 2
Add the utility file
lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));} - 3
Copy and paste the code into your project
Loading source⦠- 4
Update the import paths to match your project
Point
@/lib/utilsto your utility file if your path aliases differ.
Usage
"use client"
import { useRef } from "react"import ScrollProgress from "@/components/ui/scroll-progress"
const sections = [ { id: "intro", label: "Introduction" }, { id: "usage", label: "Usage" }, { id: "faq", label: "FAQ" },]
export function Demo() { const scrollRef = useRef<HTMLElement>(null)
return ( <main ref={scrollRef} className="relative h-full overflow-auto"> <ScrollProgress containerRef={scrollRef} sections={sections} />
<section id="intro">{/* ... */}</section> <section id="usage">{/* ... */}</section> <section id="faq">{/* ... */}</section> </main> )}
// Tracks the window with no container ref:// <ScrollProgress sections={sections} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
sections | Array<{ id: string; label: string }> | Default: [] | Ordered sections shown as the reader moves and listed in the menu. Each id must match an element id present in the scrolled content. |
containerRef | React.RefObject<HTMLElement | null> | Default: - | Scroll container to track and scroll within. Defaults to the window when omitted. |
offset | number | Default: 120 | Distance in pixels below the scroller's top edge that a section must cross to be marked active. |
className | string | Default: - | Extra classes merged onto the fixed root wrapper. Use it to reposition the pill. |
Scroll to fill the ring and watch the active section label crossfade in. Click the pill to morph it into a squircle menu, then tap any section to smooth-scroll there. Click outside or press Escape to close.
Credits and license
Most components here are recreations of great work from around the web. I don't claim to be the original creator - this is my attempt to reverse-engineer, replicate, and often add a few extra features. I've tried to credit everyone; if I missed someone, let me know.
- Free to use and modify in both personal and commercial projects.
- Attribution to Mox UI is appreciated when using a component.
- Please don't resell the components as your own kit.