Navigation

Scroll Progress

Reading-progress hairline driven by a spring-smoothed scaleX that hides at zero, for pages or any scrollable container.

Install

npx shadcn@latest add @paragon/scroll-progress

scroll-progress.tsx

"use client";

import * as React from "react";
import {
  motion,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";

export interface ScrollProgressProps {
  /**
   * Scrollable element to track. Omit to track the page — the bar then sits
   * fixed at the top of the viewport. When tracking a container, position
   * the bar yourself (e.g. `absolute top-0` inside a relative wrapper).
   */
  container?: React.RefObject<HTMLElement | null>;
  className?: string;
}

/**
 * Reading-progress hairline. scaleX is driven by useScroll through a spring
 * (stiffness 200, damping 40 — smooths jumpy wheel deltas without lagging
 * behind), so the only per-frame work is a compositor transform. Hidden at
 * 0% so untouched pages stay clean. Under prefers-reduced-motion the spring
 * is bypassed and the bar tracks scroll position exactly.
 */
export function ScrollProgress({ container, className }: ScrollProgressProps) {
  const reducedMotion = useReducedMotion();
  const { scrollYProgress } = useScroll(container ? { container } : undefined);
  const spring = useSpring(scrollYProgress, {
    stiffness: 200,
    damping: 40,
    restDelta: 0.001,
  });
  const progress = reducedMotion ? scrollYProgress : spring;
  // Hide until there is real progress; fades in over the first 2%.
  const opacity = useTransform(progress, [0, 0.02], [0, 1]);

  return (
    <motion.div
      aria-hidden
      data-slot="scroll-progress"
      className={cn(
        "pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 origin-left bg-primary",
        className,
      )}
      style={{ scaleX: progress, opacity }}
    />
  );
}