# Scroll Progress (Moonarc)

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.

- Import: `import ScrollProgress from '@moonarc/core/ScrollProgress'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/scroll-progress.json`
- Tier A · category scroll · trigger scroll
- Readout: `<ScrollProgress height={2}>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: the bar is not rendered (Firefox)
- Measured cost: 0 B JS (CSS 4.8 kB raw)
- Page: https://moonarc.dev/components/scroll-progress/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `height` | `number` | `2` | Thickness in px. |
| `color` | `string` | `'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. |

## Usage

```astro
<ScrollProgress color="var(--accent)" />
```

## 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.

## Craft

- scale on a transform-origin at the left: one compositor property, no width recalculation.
- Hidden where scroll timelines are missing, because a full bar on load is worse than no bar.
- aria-hidden: the reading position is already in the scrollbar for assistive tech.

## Replaces

- ScrollProgress (Magic UI)
- framer-motion useScroll progress bar

## Source

```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>

```
