Scroll Progress
Composable page progress: a line, section rail, liquid dots, or an orbit.
Scroll through the examples below. Each indicator stays in view while its section scrolls from start to finish. The next example then takes its place. You can also open the separate full page demo.
Composition
ScrollProgress is a polymorphic HTML container. It renders a div by default,
or the tag given with as. It does not use a custom element or a client framework.
With no children, it supplies a track and fill:
<ScrollProgress />
<ScrollProgress orientation="horizontal" />
The default vertical line sits at the right edge. The horizontal line sits at the top. Both track vertical scroll distance, from 0 at the top to 1 at the bottom. A page with no scroll range stays at 0.
The previews use scrollTarget="html" and a progressTarget section. A sticky
wrapper keeps the indicator visible during that section.
Line
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
import { ScrollProgress } from "@/components/ui/scroll-progress"
---
<section data-progress-section class="relative min-h-[160vh] w-full">
<div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
<div class="text-center">
<p class="font-medium">Line</p>
<p class="text-sm text-muted-foreground">
Scroll through this section to fill the indicator.
</p>
</div>
<ScrollProgress
scrollTarget="html"
progressTarget="[data-progress-section]"
position="relative"
aria-hidden="true"
/>
<p class="text-xs text-muted-foreground">
0% at the start · 100% at the end
</p>
</div>
</section>
Tracks and fills
Use parts when you need more control. Each part accepts class, style, native
HTML attributes, and a slot. The root, track, fill, and indicator also accept as.
---
import {
ScrollProgress,
ScrollProgressTrack,
ScrollProgressFill,
} from "@/components/ui/scroll-progress"
---
<ScrollProgress orientation="horizontal">
<ScrollProgressTrack class="h-1!">
<ScrollProgressFill />
</ScrollProgressTrack>
</ScrollProgress>
ScrollProgressTrack supplies the dimensions and background. Its variants are
line, rail, gooey, and orbit. ScrollProgressFill supplies a growing line
or an SVG ring with variant="orbit". You can omit the fill.
The --scroll-progress CSS property is a number from 0 to 1. Custom content can
use it to draw a different shape without another scroll script.
Indicators
ScrollProgressIndicator is optional. The default rail variant groups section
items. The gooey variant supplies four liquid dots and a moving blob. The
orbit variant supplies a dot that travels around the ring. A slot replaces
its default content, so you can supply your own marks or SVG.
Gooey
Gooey
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
import {
ScrollProgress,
ScrollProgressTrack,
ScrollProgressIndicator,
} from "@/components/ui/scroll-progress"
---
<section data-progress-section class="relative min-h-[160vh] w-full">
<div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
<div class="text-center">
<p class="font-medium">Gooey</p>
<p class="text-sm text-muted-foreground">
Scroll through this section to fill the indicator.
</p>
</div>
<ScrollProgress
scrollTarget="html"
progressTarget="[data-progress-section]"
position="relative"
aria-hidden="true"
>
<ScrollProgressTrack variant="gooey">
<ScrollProgressIndicator variant="gooey" />
</ScrollProgressTrack>
</ScrollProgress>
<p class="text-xs text-muted-foreground">
0% at the start · 100% at the end
</p>
</div>
</section>
Orbit
The ring and orbiting dot are separate parts. Omit the indicator for a plain ring.
Orbit
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
import {
ScrollProgress,
ScrollProgressTrack,
ScrollProgressFill,
ScrollProgressIndicator,
} from "@/components/ui/scroll-progress"
---
<section data-progress-section class="relative min-h-[160vh] w-full">
<div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
<div class="text-center">
<p class="font-medium">Orbit</p>
<p class="text-sm text-muted-foreground">
Scroll through this section to fill the indicator.
</p>
</div>
<ScrollProgress
scrollTarget="html"
progressTarget="[data-progress-section]"
position="relative"
aria-hidden="true"
>
<ScrollProgressTrack variant="orbit">
<ScrollProgressFill variant="orbit" />
<ScrollProgressIndicator variant="orbit" />
</ScrollProgressTrack>
</ScrollProgress>
<p class="text-xs text-muted-foreground">
0% at the start · 100% at the end
</p>
</div>
</section>
Horizontal
Orientation changes the layout, not the scroll axis. A horizontal line or a row of dots can show progress through the same vertical page.
Horizontal
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
import { ScrollProgress } from "@/components/ui/scroll-progress"
---
<section data-progress-section class="relative min-h-[160vh] w-full">
<div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
<div class="text-center">
<p class="font-medium">Horizontal</p>
<p class="text-sm text-muted-foreground">
Scroll through this section to fill the indicator.
</p>
</div>
<ScrollProgress
scrollTarget="html"
progressTarget="[data-progress-section]"
orientation="horizontal"
position="relative"
class="w-full max-w-64"
aria-hidden="true"
/>
<p class="text-xs text-muted-foreground">
0% at the start · 100% at the end
</p>
</div>
</section>
Section links
Use as="nav" and give the navigation an accessible name. Items are native
anchors. Use href="#section-id" to refer to a section; id remains the native
ID of the link itself. This avoids duplicate IDs on the link and its target.
---
import {
ScrollProgress,
ScrollProgressTrack,
ScrollProgressIndicator,
ScrollProgressItem,
} from "@/components/ui/scroll-progress"
const items = [
{ id: "about", label: "About" },
{ id: "work", label: "Work" },
]
---
<ScrollProgress as="nav" aria-label="Page sections" orientation="vertical">
<ScrollProgressTrack variant="rail">
<ScrollProgressIndicator>
{items.map(item => (
<ScrollProgressItem href={`#${item.id}`}>
{item.label}
</ScrollProgressItem>
))}
</ScrollProgressIndicator>
</ScrollProgressTrack>
</ScrollProgress>
<section id="about">...</section>
<section id="work">...</section>
Keep items in document order. Content can be text, an icon, or both. A current,
hovered, or focused item shows its content and expands its mark. To omit visible
labels, leave the slot empty and supply an aria-label:
Start — scroll to the next section.
Middle — scroll to the next section.
Finish — scroll to the next section.
---
import {
ScrollProgress,
ScrollProgressTrack,
ScrollProgressIndicator,
ScrollProgressItem,
} from "@/components/ui/scroll-progress"
const id = `rail-${crypto.randomUUID()}`
const sections = ["Start", "Middle", "Finish"]
---
<section
data-progress-section
class="relative grid w-full grid-cols-[auto_1fr] gap-6"
>
<div class="relative">
<ScrollProgress
as="nav"
aria-label="Demo sections"
scrollTarget="html"
progressTarget="[data-progress-section]"
position="relative"
class="sticky top-24"
>
<ScrollProgressTrack variant="rail">
<ScrollProgressIndicator>
{sections.map((label, i) => (
<ScrollProgressItem href={`#${id}-${i}`} aria-label={label}>
{label}
</ScrollProgressItem>
))}
</ScrollProgressIndicator>
</ScrollProgressTrack>
</ScrollProgress>
</div>
<div>
{sections.map((label, i) => (
<section
id={`${id}-${i}`}
class="flex min-h-[70vh] scroll-mt-24 items-start border-t py-8"
>
<p class="text-sm text-muted-foreground">
{label} — scroll to the next section.
</p>
</section>
))}
</div>
</section>
Empty items get a fallback accessible name from their fragment target. Supply a
clear aria-label for an icon-only item. No item list, labels, or section IDs
are needed when you only want total progress.
Position and scroll source
position="fixed" keeps the indicator at the viewport edge. Use
position="absolute" to anchor it inside a positioned ancestor, or
position="relative" to keep it in normal flow. Tailwind classes can set offsets
or change positioning to sticky.
The script finds the nearest ancestor with vertical overflow: auto, scroll,
or hidden. If there is none, it tracks the document.
A relative class controls the containing block for absolute positioning;
it does not make that element scrollable. Use overflow-clip when you need
clipping without a scroll source. Fixed positioning does not change which
ancestor the script tracks.
To select a scroll container yourself, pass a CSS selector to scrollTarget:
<div id="reader" class="h-96 overflow-y-auto">
<article class="min-h-[72rem]">...</article>
</div>
<ScrollProgress scrollTarget="#reader" />
The indicator can be outside the selected container. The selector uses the first
matching HTML element in the document. Section links must point to sections
inside that container. Use scrollTarget="html" or scrollTarget="body" to
track the page explicitly.
The target must exist when the component initializes. An invalid selector or a missing target leaves the indicator empty. It does not track a different source. An omitted or blank selector keeps automatic parent detection.
Track one section
Use progressTarget to measure one section within the scroll container. The
nearest matching ancestor is used first, then the first document match.
<section data-reading-section class="min-h-[160vh]">
<div class="sticky top-24">
<ScrollProgress
scrollTarget="html"
progressTarget="[data-reading-section]"
position="relative"
/>
</div>
</section>
Progress starts when the section top reaches the scroll viewport top. It ends
when the section bottom reaches the viewport bottom. The section must be taller
than the viewport; otherwise progress stays at 0. A missing or invalid target
also stays at 0. Omit progressTarget to measure the whole scroll container.
With Scroll Area
Scroll Area works with the same component. Give its
scrolling viewport an ID through viewportProps, then use that ID as the target:
<ScrollArea class="h-80" viewportProps={{ id: "reading-viewport" }}>
<article>...</article>
</ScrollArea>
<ScrollProgress scrollTarget="#reading-viewport" />
The viewport must have enough content to scroll. Target the inner viewport,
not the outer ScrollArea wrapper. A ScrollProgress placed inside the viewport
can omit scrollTarget and use automatic parent detection. Progress tracks the
vertical scroll distance, including when the indicator uses a horizontal layout.
Behavior and access
One passive scroll listener per instance schedules at most one update per
animation frame. It writes --scroll-progress; CSS draws each part. An
IntersectionObserver updates section selection as sections cross a reading
line 45% down the viewport. The
first and last links are selected at the scroll boundaries, including a short
final section. Normal scroll updates do not scan section positions.
This implementation uses JavaScript for progress in all browsers. This keeps page scrolling and nested scrolling on the same path, including fixed elements inside a scroll container. With JavaScript off, native links still work and the fill stays empty.
Resize observers refresh the progress when content size changes. The script removes instance listeners and observers when a root is removed or Astro changes the page. The fill is decorative and does not announce every scroll update. Links retain native keyboard and fragment navigation.
A reduced-motion preference removes mark transitions, the gooey filter, and the orbiting dot. The line and ring still show progress. The component does not set smooth scrolling.
Installation
bunx --bun shadcn@latest add @gnosticai/scroll-progress
API reference
| Root prop | Values | Default |
|---|---|---|
as |
An HTML tag | div |
orientation |
vertical, horizontal |
vertical |
position |
fixed, absolute, relative |
fixed |
scrollTarget |
A CSS selector for the scroll container | Nearest scrollable ancestor |
progressTarget |
A CSS selector for the section to measure | Whole scroll container |
| Part | variant values |
Default |
|---|---|---|
ScrollProgressTrack |
line, rail, gooey, orbit |
line |
ScrollProgressIndicator |
rail, gooey, orbit |
rail |
ScrollProgressFill |
line, orbit |
line |
The track and indicator default to div; the fill defaults to span. Each
accepts an as prop. ScrollProgressItem is an anchor with a required href
fragment, optional slot content, and native anchor attributes.
Theme and layout
Parts use the primary, foreground, background, and border tokens. Use Tailwind classes to set dimensions, offsets, and colors. Leave room beside content for fixed section links. Keep the rail short enough to fit on the smallest screen.
The root, track, fill, and indicator classes use CVA. Their variant functions and inferred types are exported from the component index, as with Button and Badge.
The earlier items, variant, and source props on the root have been replaced
by slots, part variants, and automatic source detection with an optional
scrollTarget selector.
Related choices
Use Progress for task completion and Scroll Area for a bounded native scroll panel. The section rail takes its direction from Albina Nikiforova’s site.