Interactive
ScrollProgress
Shows how far through the page you are. Pick a bar across the top, or a ring with the percent that takes you back up on a click. Follows the page or any scrolling box.
Download
- React
- Tailwind CSS v4
- motion
components/ui/scroll-progress.tsx
"use client";
import { useState, type RefObject } from "react";
import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from "motion/react";
type ScrollProgressProps = {
/** "bar" runs across the top, "ring" is a round back-to-top button. */
variant?: "bar" | "ring";
/** Scrolling box to follow. Leave out to use the page. */
container?: RefObject<HTMLElement | null>;
/** Bar height or ring width in pixels. */
size?: number;
/** Colour of the progress. */
accent?: string;
/** Read by screen readers. */
label?: string;
/** Replaces the default place and look. The bar is fixed to the top of the page, the ring to the bottom right. */
className?: string;
};
const FOLLOW = { stiffness: 200, damping: 30, restDelta: 0.001 };
const STROKE = 3;
/* ScrollProgress: shows how far through the page you are, as a bar across the top or a ring that takes you back up. */
export function ScrollProgress({ variant = "bar", container, size, accent = "#a78bfa", label = "Reading progress", className }: ScrollProgressProps) {
const reduce = useReducedMotion();
const { scrollYProgress } = useScroll(container ? { container } : undefined);
const smooth = useSpring(scrollYProgress, FOLLOW);
const progress = reduce ? scrollYProgress : smooth;
const [percent, setPercent] = useState(0);
useMotionValueEvent(scrollYProgress, "change", (v) => setPercent(Math.round(v * 100)));
// Inside a scrolling box, fixed would pin it to the window, so it sticks to the box instead.
const place = container ? "sticky" : "fixed";
if (variant === "bar") {
const height = size ?? 3;
return (
<motion.div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className={className ?? `${place} top-0 right-0 left-0 z-50`}
style={{ height, scaleX: progress, transformOrigin: "left", background: accent, boxShadow: `0 0 12px ${accent}` }}
/>
);
}
const width = size ?? 52;
const radius = (width - STROKE) / 2;
const toTop = () => (container?.current ?? window).scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
return (
<button
type="button"
onClick={toTop}
aria-label={`${label} ${percent}%. Back to top`}
className={`grid place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${
className ?? `${place} right-5 bottom-5 z-50 bg-[#15131f] text-white shadow-lg`
}`}
style={{ width, height: width }}
>
<svg viewBox={`0 0 ${width} ${width}`} aria-hidden className="col-start-1 row-start-1 -rotate-90">
<circle cx={width / 2} cy={width / 2} r={radius} fill="none" stroke="currentColor" strokeOpacity={0.15} strokeWidth={STROKE} />
<motion.circle cx={width / 2} cy={width / 2} r={radius} fill="none" stroke={accent} strokeWidth={STROKE} style={{ pathLength: progress }} />
</svg>
<span aria-hidden className="col-start-1 row-start-1 text-xs font-semibold tabular-nums">
{percent === 100 ? "โ" : percent}
</span>
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/scroll-progress.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/scroll-progress-demo.tsx
"use client"; import { useRef } from "react"; import { ScrollProgress } from "@/components/ui/scroll-progress"; const PARAGRAPHS = [ "The first draft of anything is mostly a list of things you will take out later.", "Short pages get read. Long pages get scrolled, so give the reader a sense of the road left.", "A bar across the top does that without asking for any attention at all.", "A ring in the corner does the same job and also takes you back to the start.", "Both of them read the same number, so use whichever suits the page.", "That is the whole idea. You reached the end, so press the arrow to go back up.", ]; export default function ScrollProgressExample() { // The preview scrolls inside its own box. On a real page, leave out `container`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="relative h-full overflow-y-auto bg-[#0b0a14]"> <ScrollProgress container={box} /> <div className="mx-auto max-w-md space-y-12 p-5 pr-16 pb-16 text-sm leading-relaxed text-white/70"> <p className="text-white/55">Scroll inside this box.</p> {PARAGRAPHS.map((text) => ( <p key={text}>{text}</p> ))} </div> <ScrollProgress variant="ring" container={box} size={48} className="sticky bottom-3 z-10 mr-3 ml-auto bg-[#15131f] text-white shadow-lg" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| variant | "bar" | "ring" | "bar" | Bar across the top, or a round back-to-top button. |
| container | RefObject<HTMLElement | null> | โ | Scrolling box to follow. Leave out to use the page. |
| size | number | 3 bar, 52 ring | Bar height or ring width in pixels. |
| accent | string | "#a78bfa" | Colour of the progress. |
| label | string | "Reading progress" | Read by screen readers. |
| className | string | fixed to the page | Replaces the default place and look. |