Scroll Progress Bar for React
Fixed bar at the top that scales horizontally with scroll progress. Uses Framer Motion for smooth updates.
Component
"use client";
import {
motion,
useMotionValue,
useSpring,
} from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ScrollProgressProps {
className?: string;
containerRef?: React.RefObject<HTMLElement | null>;
inline?: boolean;
}
export function ScrollProgress({
className,
containerRef,
inline,
}: ScrollProgressProps) {
const rawProgress = useMotionValue(0);
const smoothProgress = useSpring(rawProgress, {
damping: 40,
stiffness: 300,
});
const [ready, setReady] = useState(false);
const initialised = useRef(false);
useEffect(() => {
const scrollEl: HTMLElement | Window =
containerRef?.current ?? window;
const getProgress = () => {
if (containerRef?.current) {
const el = containerRef.current;
const docHeight = el.scrollHeight - el.clientHeight;
return docHeight > 0 ? el.scrollTop / docHeight : 0;
}
const docHeight =
document.documentElement.scrollHeight -
window.innerHeight;
return docHeight > 0 ? window.scrollY / docHeight : 0;
};
const handleScroll = () =>
rawProgress.set(getProgress());
scrollEl.addEventListener("scroll", handleScroll, {
passive: true,
});
if (!initialised.current) {
initialised.current = true;
const p = getProgress();
rawProgress.jump(p);
smoothProgress.jump(p);
requestAnimationFrame(() => setReady(true));
}
return () =>
scrollEl.removeEventListener("scroll", handleScroll);
}, [containerRef, rawProgress, smoothProgress]);
const bar = (
<motion.div
className="h-full w-full bg-primary"
style={{
scaleX: smoothProgress,
transformOrigin: "left",
}}
initial={{ opacity: 0 }}
animate={{ opacity: ready ? 1 : 0 }}
transition={{ duration: 0.15, ease: "linear" }}
aria-hidden="true"
/>
);
if (inline) {
return (
<div
className={cn(
"sticky top-0 z-10 h-1 w-full overflow-hidden rounded-t-lg bg-muted/50",
className,
)}
>
{bar}
</div>
);
}
return (
<motion.div
className={cn(
"fixed top-0 left-0 z-50 h-1 w-full bg-primary",
className,
)}
style={{
scaleX: smoothProgress,
transformOrigin: "left",
}}
initial={{ opacity: 0 }}
animate={{ opacity: ready ? 1 : 0 }}
transition={{ duration: 0.15, ease: "linear" }}
aria-hidden="true"
/>
);
}Installation
1. Install dependencies
pnpm add framer-motion2. Copy the component file
"use client";
import {
motion,
useMotionValue,
useSpring,
} from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface ScrollProgressProps {
className?: string;
containerRef?: React.RefObject<HTMLElement | null>;
inline?: boolean;
}
export function ScrollProgress({
className,
containerRef,
inline,
}: ScrollProgressProps) {
const rawProgress = useMotionValue(0);
const smoothProgress = useSpring(rawProgress, {
damping: 40,
stiffness: 300,
});
const [ready, setReady] = useState(false);
const initialised = useRef(false);
useEffect(() => {
const scrollEl: HTMLElement | Window =
containerRef?.current ?? window;
const getProgress = () => {
if (containerRef?.current) {
const el = containerRef.current;
const docHeight = el.scrollHeight - el.clientHeight;
return docHeight > 0 ? el.scrollTop / docHeight : 0;
}
const docHeight =
document.documentElement.scrollHeight -
window.innerHeight;
return docHeight > 0 ? window.scrollY / docHeight : 0;
};
const handleScroll = () =>
rawProgress.set(getProgress());
scrollEl.addEventListener("scroll", handleScroll, {
passive: true,
});
if (!initialised.current) {
initialised.current = true;
const p = getProgress();
rawProgress.jump(p);
smoothProgress.jump(p);
requestAnimationFrame(() => setReady(true));
}
return () =>
scrollEl.removeEventListener("scroll", handleScroll);
}, [containerRef, rawProgress, smoothProgress]);
const bar = (
<motion.div
className="h-full w-full bg-primary"
style={{
scaleX: smoothProgress,
transformOrigin: "left",
}}
initial={{ opacity: 0 }}
animate={{ opacity: ready ? 1 : 0 }}
transition={{ duration: 0.15, ease: "linear" }}
aria-hidden="true"
/>
);
if (inline) {
return (
<div
className={cn(
"sticky top-0 z-10 h-1 w-full overflow-hidden rounded-t-lg bg-muted/50",
className,
)}
>
{bar}
</div>
);
}
return (
<motion.div
className={cn(
"fixed top-0 left-0 z-50 h-1 w-full bg-primary",
className,
)}
style={{
scaleX: smoothProgress,
transformOrigin: "left",
}}
initial={{ opacity: 0 }}
animate={{ opacity: ready ? 1 : 0 }}
transition={{ duration: 0.15, ease: "linear" }}
aria-hidden="true"
/>
);
}3. Import and use
import { ScrollProgress } from "@/components/scroll-progress";
<ScrollProgress />;Usage
Import
Add the ScrollProgress import.
import { ScrollProgress } from "@/components/scroll-progress";Use
Add to your layout (e.g. root layout).
<ScrollProgress />;Guidelines
- Place in your root layout so it tracks page scroll, or pass containerRef for a custom scroll container.
- Use inline with containerRef when the bar should appear inside the scroll container (parent needs relative).
- Use className to customize height, color, or position.
- The bar scales from left to right; ensure sufficient scroll height to see the effect.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional CSS classes for the bar (e.g. height, color). |
| containerRef | React.RefObject<HTMLElement | null> | undefined | Ref to a scrollable overflow container. Omit to track window scroll instead. |
| inline | boolean | false | When true, positions the bar absolutely inside its parent (use with relative container) instead of fixed to viewport. |
Accessibility
- Decorative visual indicator only; the bar is marked aria-hidden true and exposes no role, label, or accessible name to assistive technology.
- There are no interactive controls, buttons, links, or focusable elements, so keyboard operability and focus visibility do not apply to this component.
- Scroll position is already conveyed natively by the browser scrollbar, so screen reader and keyboard users lose no information when this purely visual layer is ignored.
- It does not honour prefers-reduced-motion: the spring-driven scaleX always animates, so it should be guarded with a matchMedia('(prefers-reduced-motion: reduce)') check to disable the spring for users who prefer reduced motion.
Performance
- Animates only scaleX (a transform) and opacity, both of which are GPU compositable and avoid layout or paint, making each scroll update cheap.
- The scroll listener is registered with passive true so it never blocks the scroll thread, and it is removed in the useEffect cleanup to prevent leaks.
- Uses Framer Motion useMotionValue and useSpring to smooth updates outside of React render, so scrolling does not trigger component re-renders.
- A single requestAnimationFrame gates the initial opacity reveal, and no IntersectionObserver, will-change, or timers are used, keeping the footprint minimal.
- Motion is never gated by reduced-motion or visibility, so the spring keeps animating on every scroll even for users who would prefer it disabled.
Examples
Basic
Add to your layout for a global scroll indicator.
import { ScrollProgress } from "@/components/scroll-progress";
export function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<ScrollProgress />
{children}
</>
);
}Custom styling
Customize height and color with className.
import { ScrollProgress } from "@/components/scroll-progress";
<ScrollProgress className="h-0.5 bg-violet-500" />;With container ref
Track scroll inside an overflow container. Use inline so the bar stays at top of the container.
import { useRef } from "react";
import { ScrollProgress } from "@/components/scroll-progress";
export function ScrollableSection() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="relative h-96 overflow-y-auto"
>
<ScrollProgress containerRef={containerRef} inline />
<div className="p-4">{/* content */}</div>
</div>
);
}Related reading
- Scroll Indicator with Framer Motion, The Framer Motion scroll indicator this is based on
- Smooth Scroll in React, Pairing the progress bar with smooth scrolling
Last updated on Jun 25