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.jsonUsage
import { ScrollProgress } from "@/components/scroll-progress"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<ScrollProgress />
+ native element props| Prop | Type | Default |
|---|---|---|
target | React.RefObject<HTMLElement | null> | none |
position | "top" | "bottom" | 'top' |
Component source
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface ScrollProgressProps extends React.ComponentProps<'div'> {
target?: React.RefObject<HTMLElement | null>;
position?: 'top' | 'bottom';
}
const 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 };