Scroll Progress Bar for React
Fixed bar at the top that scales horizontally with scroll progress. Uses Framer Motion for smooth updates.
Component
Scroll down to see the progress bar fill.
Section 1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 4
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 5
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 6
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 7
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 8
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 9
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 10
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 11
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 12
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
"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.
Scroll down to see the progress bar fill.
Section 1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 4
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 5
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 6
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 7
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 8
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 9
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 10
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 11
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 12
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
import { ScrollProgress } from "@/components/scroll-progress";
export function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<ScrollProgress />
{children}
</>
);
}Custom styling
Customize height and color with className.
Scroll down to see the progress bar fill.
Section 1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 4
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 5
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 6
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 7
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 8
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 9
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 10
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 11
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 12
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
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.
Scroll down to see the progress bar fill.
Section 1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 2
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 4
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 5
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 6
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 7
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 8
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 9
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 10
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 11
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
Section 12
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.
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 19