Skip to content
gnosticaignostic-ui
Esc
↑↓navigate↵open⌘Jpreview
On this page

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>
Open preview in a new tab

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>
Open preview in a new tab

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>
Open preview in a new tab

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>
Open preview in a new tab

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>
Open preview in a new tab

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.

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.

Was this page helpful?