Skip to content

Components / Scroll

Scroll Progress

A hairline along the top of the viewport that fills as the page is read. Zero JavaScript: its scale is bound to the document's scroll timeline, so nothing listens to scroll and it is never a frame behind.

Live demo

scroll inside

Line 1: the bar above tracks the scroll of this box.

Line 2: the bar above tracks the scroll of this box.

Line 3: the bar above tracks the scroll of this box.

Line 4: the bar above tracks the scroll of this box.

Line 5: the bar above tracks the scroll of this box.

Line 6: the bar above tracks the scroll of this box.

Line 7: the bar above tracks the scroll of this box.

Line 8: the bar above tracks the scroll of this box.

Line 9: the bar above tracks the scroll of this box.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: the bar is not rendered (Firefox).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ScrollProgress from '@moonarc/core/ScrollProgress';
---
<ScrollProgress color="var(--accent)" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/scroll-progress.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

ScrollProgress.astro
---
/**
 * ScrollProgress — a hairline that fills as the page is read, zero JS.
 *
 * scale-x is bound to the document scroll timeline; nothing listens to
 * scroll. Browsers without scroll timelines do not show the bar at all,
 * which is better than a bar that is always full.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Thickness in px. */
  height?: number;
  /** Bar colour. */
  color?: string;
  /** Edge of the viewport. */
  position?: 'top' | 'bottom';
  /** Which scroller to track: the document, or the nearest scrolling ancestor (then the bar is sticky inside it). */
  scope?: 'root' | 'nearest';
}

const { height = 2, color = 'currentColor', position = 'top', scope = 'root', class: className, ...rest } = Astro.props;
---

<div class:list={['ma-scroll-progress', className]} aria-hidden="true" data-position={position === 'bottom' ? 'bottom' : undefined} data-scope={scope === 'nearest' ? 'nearest' : undefined} style={`--ma-sp-h:${height}px;--ma-sp-color:${color}`} {...rest}></div>

<style is:global>
  @layer components {
    :where(.ma-scroll-progress) {
      display: none;
      position: fixed;
      inset: 0 0 auto;
      z-index: 50;
      height: var(--ma-sp-h, 2px);
      background: var(--ma-sp-color, currentColor);
      transform-origin: 0 50%;
      scale: 0 1;
      pointer-events: none;
    }
    :where(.ma-scroll-progress[data-position='bottom']) {
      inset: auto 0 0;
    }
    :where(.ma-scroll-progress[data-scope='nearest']) {
      position: sticky;
      inset: 0 0 auto;
      margin-bottom: calc(var(--ma-sp-h, 2px) * -1);
    }
    @supports (animation-timeline: scroll()) {
      :where(.ma-scroll-progress) {
        display: block;
        animation: ma-scroll-progress linear both;
      }
      :where(html) :where(.ma-scroll-progress) {
        animation-timeline: scroll(root block);
      }
      :where(html) :where(.ma-scroll-progress[data-scope='nearest']) {
        animation-timeline: scroll(nearest block);
      }
    }
    @media (prefers-reduced-motion: reduce) {
      /* progress is information, not motion: it stays */
      :where(.ma-scroll-progress) {
        transition: none;
      }
    }
  }

  @keyframes ma-scroll-progress {
    to {
      scale: 1 1;
    }
  }
</style>

Props

PropTypeDefaultDescription
heightnumber2Thickness in px.
colorstring'currentColor'Bar colour.
position'top' | 'bottom''top'Edge of the viewport.
scope'root' | 'nearest''root'Track the document, or the nearest scrolling ancestor; with nearest, the bar sticks to the top of that scroller.

Reduced motion

Unchanged: the bar reports position and does not animate on its own.

With ClientRouter

CSS-only; the new page's bar starts at its own scroll position.

Why it is built this way

Replaces: ScrollProgress (Magic UI) · framer-motion useScroll progress bar. See the migration table.

Parallax

Scroll

An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.

Chrome 115 · not Firefox · Safari 26scroll

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown