Scroll Progress

Fixed reading progress bar. Tracks document scroll by default or a scoped container ref.

Example

Installation

npx shadcn@latest add https://hirael.com/r/scroll-progress.json

API

<ScrollProgress />

+ native element props
PropTypeDefault
targetReact.RefObject<HTMLElement | null>
position"top" | "bottom""top"

Component source

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export type ScrollProgressProps = React.ComponentProps<"div"> & {
  target?: React.RefObject<HTMLElement | null>;
  position?: "top" | "bottom";
};

function ScrollProgress({
  className,
  style,
  target,
  position = "top",
  ...props
}: ScrollProgressProps) {
  const barRef = React.useRef<HTMLDivElement | null>(null);

  React.useEffect(() => {
    const bar = barRef.current;
    if (!bar) return;

    let frame = 0;
    let lastProgress = -1;

    const apply = () => {
      frame = 0;
      let progress: number;
      if (target?.current) {
        const el = target.current;
        const max = el.scrollHeight - el.clientHeight;
        progress = max > 0 ? el.scrollTop / max : 0;
      } else {
        const doc = document.documentElement;
        const max = doc.scrollHeight - doc.clientHeight;
        progress = max > 0 ? doc.scrollTop / max : 0;
      }
      if (Math.abs(progress - lastProgress) < 0.001) return;
      lastProgress = progress;
      bar.style.transform = `scaleX(${progress})`;
    };

    const schedule = () => {
      if (frame) return;
      frame = requestAnimationFrame(apply);
    };

    const source: HTMLElement | Window = target?.current ?? window;
    apply();
    source.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule, { passive: true });
    return () => {
      if (frame) cancelAnimationFrame(frame);
      source.removeEventListener("scroll", schedule);
      window.removeEventListener("resize", schedule);
    };
  }, [target]);

  return (
    <div
      ref={barRef}
      data-slot="scroll-progress"
      aria-hidden
      className={cn(
        "pointer-events-none fixed inset-x-0 z-50 h-1 origin-left bg-foreground/80 rtl:origin-right",
        position === "top" ? "top-0" : "bottom-0",
        className,
      )}
      style={{
        transform: "scaleX(0)",
        willChange: "transform",
        ...style,
      }}
      {...props}
    />
  );
}

export { ScrollProgress };