# Scroll Progress

The ScrollProgress component fills a thin bar as the reader moves through a page or a panel, scaling a single transform rather than resizing an element so it costs nothing on the scroll frame.

- **Category:** Component
- **Docs:** https://uibeats.com/docs/component/scroll-progress
- **Registry:** https://uibeats.com/r/scroll-progress.json
- **License:** MIT
- **npm dependencies:** `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/scroll-progress.json
```

Or install the dependencies and copy the source below:

```bash
npm install motion
```

## When to use

For long reads where the scrollbar alone does not answer how much is left. Pass a container to track a panel, or omit it to track the whole page.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `container` | `RefObject<HTMLElement \| null>` | `-` | Scroll container to track. Omit to track the window, which also switches the bar from absolute to fixed positioning |
| `height` | `number` | `3` | Thickness of the bar, in pixels |
| `color` | `string` | `var(--primary)` | Any CSS colour or gradient |
| `position` | `'top' \| 'bottom'` | `top` | Which edge of the container the bar sits on |
| `smooth` | `boolean` | `true` | Ease the bar toward the true position with a spring instead of tracking raw scroll deltas |
| `className` | `string` | `''` | Additional CSS classes for styling |

## Example usage

```tsx
"use client";
import { useRef } from "react";
import { ScrollProgress } from "@/components/demo/component/scroll-progress";

const ARTICLE = [
  "Motion is a language, and most interfaces speak it badly.",
  "A transition that runs too long reads as lag. One that skips a frame reads as a bug.",
  "The fix is to move only what actually changed, and to move it the shortest distance that still explains itself.",
  "Everything else holds still, so the one thing that moved is the thing you notice.",
];

const ScrollProgressUsage = () => {
  const scrollArea = useRef<HTMLDivElement>(null);

  return (
    // The bar wraps the scroll area rather than sitting inside it, so it stays
    // put while the text moves. Drop `container` to track the whole page.
    <div className="relative w-full max-w-sm overflow-hidden rounded-xl border bg-card">
      <ScrollProgress container={scrollArea} />

      <div ref={scrollArea} className="h-56 overflow-y-auto p-5">
        <h3 className="mb-3 text-base font-semibold">On motion</h3>
        {ARTICLE.map((paragraph) => (
          <p
            key={paragraph}
            className="mb-4 text-sm leading-relaxed text-muted-foreground"
          >
            {paragraph}
          </p>
        ))}
      </div>
    </div>
  );
};

export default ScrollProgressUsage;
```

## Source

Save as `components/ui/scroll-progress.tsx`.

```tsx
"use client";

import type { RefObject } from "react";
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
import { cn } from "@/lib/utils";

interface ScrollProgressProps {
  /**
   * Scroll container to track. Omit to track the window.
   *
   * With a container the bar is positioned `absolute`, so it belongs inside a
   * `relative` wrapper *around* the scroll area rather than inside the area
   * itself: a bar inside the scroller would scroll away with the content.
   */
  container?: RefObject<HTMLElement | null>;
  /** Thickness of the bar, in pixels. */
  height?: number;
  /** Any CSS colour or gradient. */
  color?: string;
  position?: "top" | "bottom";
  /** Ease the bar toward the true position instead of tracking it exactly. */
  smooth?: boolean;
  className?: string;
}

/**
 * A bar that fills as its scroll container is read.
 *
 * The bar is scaled along X rather than resized: `scaleX` is a compositor-only
 * transform, so the browser never runs layout for a value that changes on every
 * scroll frame, and the whole thing costs nothing on the main thread.
 *
 * `smooth` runs the progress through a spring. That is the difference between a
 * bar that judders with a trackpad's raw deltas and one that glides, and it
 * matters most on exactly the long pages this component is for.
 */
export function ScrollProgress({
  container,
  height = 3,
  color = "var(--primary)",
  position = "top",
  smooth = true,
  className = "",
}: ScrollProgressProps) {
  const { scrollYProgress } = useScroll(container ? { container } : undefined);
  const smoothed = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 26,
    restDelta: 0.001,
  });
  const prefersReducedMotion = useReducedMotion();

  /*
   * The bar itself survives reduced motion: it moves only because the reader
   * is scrolling, and pinning it would just make it wrong. The spring does
   * not: it keeps travelling after the scroll has stopped, which is motion the
   * reader did not ask for and cannot stop.
   */
  const followsSpring = smooth && !prefersReducedMotion;

  return (
    <motion.div
      // The bar restates progress the content already conveys, and it changes
      // on every frame of a scroll. Announcing that is noise, not information.
      aria-hidden="true"
      className={cn(
        "left-0 z-50 w-full origin-left",
        container ? "absolute" : "fixed",
        position === "top" ? "top-0" : "bottom-0",
        className,
      )}
      style={{
        scaleX: followsSpring ? smoothed : scrollYProgress,
        height,
        background: color,
      }}
    />
  );
}

export default ScrollProgress;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
