Reading-progress hairline driven by a spring-smoothed scaleX that hides at zero, for pages or any scrollable container.
npx shadcn@latest add @paragon/scroll-progress"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 }}
/>
);
}