Scroll Progress Indicator in React: Bar, Circle and Sidebar Dots
Build a scroll progress bar, circular ring, and sidebar dot indicator in React using useRef and scroll events — no library needed.
Why Scroll Progress Indicators Actually Matter
Nobody wants to guess how far through a 4,000-word article they are. A scroll progress indicator fixes that in maybe 20 lines of code. It's one of those small UX details that users never consciously notice — until it's gone.
In practice, the biggest payoff isn't for long-form content — it's for single-page apps where sections bleed into each other and users lose their place. A thin 4px bar at the top of the viewport, or a set of sidebar dots jumping between sections, gives the reader a mental anchor.
There are three patterns worth knowing: the linear progress bar (horizontal strip across the top), the circular ring (great for floating CTAs or reading widgets), and the sidebar dot navigator (section-aware, like the kind Medium uses for long reads). We'll build all three from scratch — no framer-motion, no third-party library. Just React hooks and the scroll event.
Worth noting: if you're going to animate any of these, Framer Motion's useScroll and useSpring hooks can replace the manual event listener approach and give you smoother easing. We'll cover both paths.
Reading Scroll Position: The useRef + useEffect Pattern
Before you build any visual indicator, you need the scroll percentage. The formula is dead simple — scrollY / (scrollHeight - clientHeight) — but where you hook into it matters a lot for performance.
Putting a scroll listener directly on window in a useEffect is fine for most apps, but you need to be deliberate about cleanup. Here's the hook you'll reach for throughout this article:
``tsx
import { useState, useEffect } from 'react';
export function useScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const pct = docHeight > 0 ? (scrollY / docHeight) * 100 : 0;
setProgress(Math.min(Math.round(pct), 100));
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll(); // run once on mount
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return progress;
}
``
The passive: true option is important — it tells the browser you won't call preventDefault(), which lets it skip a synchronization step and keeps scrolling buttery at 120fps on high-refresh displays. Skip it and you'll see jank on Safari with a 2022 MacBook Pro. Annoying, avoidable.
One more thing — if your scrollable area is a div container instead of window, swap window for a ref to that element and use element.scrollTop vs element.scrollHeight - element.clientHeight instead.
Honestly, the Math.round is optional but I like it because floating-point scroll values on iOS (like 99.999847...) would never reach exactly 100% without it. Rounding keeps the indicator feeling clean.
Building the Top Progress Bar
The horizontal bar is the most common variant. A fixed 4px strip at the very top of the viewport, full width, filling left-to-right as you scroll. Here's the minimal implementation:
``tsx
import { useScrollProgress } from './useScrollProgress';
export function ScrollProgressBar() {
const progress = useScrollProgress();
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
height: '4px',
width: ${progress}%,
background: 'linear-gradient(90deg, #6366f1, #a78bfa)',
zIndex: 9999,
transition: 'width 80ms linear',
}}
/>
);
}
``
That transition: 'width 80ms linear' is the single most important line for feel. Without it the bar jumps between scroll event ticks. 80ms is short enough that it doesn't feel lagged, but long enough to visually smooth rapid-fire events. Go above 150ms and it starts feeling disconnected from your finger.
If you're using Tailwind, swap the inline styles for classes — but the gradient will need to be in a style prop unless you've added the gradient to your config. That said, Tailwind 4.x (released in early 2025) makes arbitrary gradient values much less awkward with the new @theme syntax.
Want a glassmorphic bar instead of a flat color fill? Add backdrop-filter: blur(8px) and a semi-transparent background, then drop it below a frosted header. The glassmorphism components on Empire UI show exactly that pattern — you can borrow the blur tokens directly.
The Circular Progress Ring
The circular variant sits in the corner — usually bottom-right — and uses an SVG stroke-dashoffset trick to animate a ring. It's a bit more code but it reads as more polished in contexts where a top bar would clash with your nav.
``tsx
function CircularProgress({ progress }: { progress: number }) {
const radius = 22;
const stroke = 3;
const normalizedRadius = radius - stroke;
const circumference = 2 * Math.PI * normalizedRadius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div style={{ position: 'fixed', bottom: 24, right: 24, zIndex: 9999 }}>
<svg height={radius * 2} width={radius * 2}>
{/* track */}
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={stroke}
r={normalizedRadius}
cx={radius}
cy={radius}
/>
{/* fill */}
<circle
stroke="#6366f1"
fill="transparent"
strokeWidth={stroke}
strokeDasharray={${circumference} ${circumference}}
style={{
strokeDashoffset,
transition: 'stroke-dashoffset 80ms linear',
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
r={normalizedRadius}
cx={radius}
cy={radius}
/>
</svg>
<span
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '9px',
fontWeight: 600,
color: '#a78bfa',
}}
>
{progress}%
</span>
</div>
);
}
``
The rotate(-90deg) on the fill circle is non-obvious. SVG circles start at the 3-o'clock position (the right edge), so without that rotation your ring fills starting from the right instead of the top. Took me 20 minutes to figure that out the first time. Now you don't have to.
Quick aside: the radius = 22 and stroke = 3 values give you a 44px total diameter, which hits Apple's minimum touch target size. If you make this a button (a common pattern — click to scroll back to top), that sizing matters.
Look, you could also achieve this with a conic-gradient on a div using CSS custom properties, and in 2024 that approach has great browser support. But the SVG path gives you much more control over the stroke cap, animation easing, and color transitions. Preference call.
Sidebar Dot Navigator for Section-Based Layouts
Sidebar dots are a different beast — instead of showing raw scroll percentage, they show *which named section you're in*. You register a list of sections by ID, use IntersectionObserver to track which one is active, and render a vertical set of dots that highlight accordingly. It's the pattern you see on portfolio sites and marketing one-pagers.
First, the section tracking hook:
``tsx
import { useState, useEffect } from 'react';
export function useActiveSection(sectionIds: string[]) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionIds.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveId(id);
},
{ rootMargin: '-40% 0px -50% 0px', threshold: 0 }
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, [sectionIds]);
return activeId;
}
``
Then the dot UI:
``tsx
const SECTIONS = [
{ id: 'intro', label: 'Intro' },
{ id: 'features', label: 'Features' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'faq', label: 'FAQ' },
];
export function SidebarDots() {
const activeId = useActiveSection(SECTIONS.map((s) => s.id));
return (
<nav
style={{
position: 'fixed',
right: 16,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
gap: 10,
zIndex: 9999,
}}
>
{SECTIONS.map(({ id, label }) => (
<a
key={id}
href={#${id}}
title={label}
style={{
width: activeId === id ? 10 : 6,
height: activeId === id ? 10 : 6,
borderRadius: '50%',
background: activeId === id ? '#6366f1' : 'rgba(255,255,255,0.3)',
transition: 'all 200ms ease',
display: 'block',
}}
/>
))}
</nav>
);
}
``
The rootMargin: '-40% 0px -50% 0px' is doing the heavy lifting. It means the observer fires when the section crosses the middle 10% of the viewport — so the dot switches right as the section header hits around eye level. Adjust those percentages based on how tall your sections are. For sections shorter than 100vh, you may need threshold: 0.3 instead.
For accessibility, always include the title attribute on those anchor dots. Screen readers will pick it up as a landmark link. If you want to go further, check out the React accessibility guide — it covers exactly this kind of non-obvious keyboard trap.
That said, sidebar dots can be visually noisy on mobile. Hide them under 768px with a media query or conditional render based on useWindowSize. The top progress bar survives mobile much better — more surface area, no risk of overlapping content.
Animating With Framer Motion's useScroll
If you're already using Framer Motion in your project, skip the manual event listener. Framer's useScroll + useSpring combo gives you a spring-physics smoothed value that feels noticeably better than CSS transition:
``tsx
import { useScroll, useSpring, motion } from 'framer-motion';
export function SpringProgressBar() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
return (
<motion.div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 4,
background: 'linear-gradient(90deg, #6366f1, #a78bfa)',
transformOrigin: '0%',
scaleX,
zIndex: 9999,
}}
/>
);
}
``
This uses scaleX instead of width — Framer animates transforms on the compositor thread, so this is GPU-accelerated and won't block the main thread during heavy JS execution. That matters in content-heavy dashboards where React is doing real work.
Honestly, the spring approach feels a hair overdone on minimalist designs — the trailing lag can look like a bug to non-designers. Dial down the stiffness or just use a linear transition if your brand is clean and fast. If you're going for something more animated and fluid, pair it with the patterns in React animation with Framer Motion.
In practice, pick one approach and stick with it across your app. Mixing a spring-animated top bar with a CSS-transitioned circular ring will read inconsistently to users who notice these things — and some users definitely do.
Putting It Together and Styling Considerations
At this point you have three independent components. The question is when to reach for which one. The bar works universally — blog posts, docs, long landing pages. The circle works best when you want a reading widget that doubles as a 'back to top' button. The sidebar dots only make sense on structured, section-based pages where the user benefits from knowing exactly where they are.
For styling, lean into whatever visual language your app already uses. If you've built with glassmorphism tokens, the bar can use a semi-transparent fill with a blur background. If you're working in a brutalist aesthetic like the neobrutalism style, go hard black border + solid fill + no transition. The mechanism is the same — only the skin changes.
Quick aside: z-index wars are real. Put all three of these at z-index: 9999 minimum. Your modal, your navbar, your cookie banner — everything fights for the top layer. Some teams use CSS custom properties like --z-overlay: 9999 and --z-progress: 9998 to keep it managed. Worth doing if you have more than three z-index values in your codebase.
Performance-wise, none of these indicators cause measurable slowdowns at 60fps. The passive scroll listener fires off the main thread, the CSS transition handles the animation on the compositor, and React re-renders are minimal because only a single progress number is updating state. Run a quick Lighthouse audit before and after — you won't see a change. If you're curious about broader performance patterns in React, the React performance guide is worth a skim.
One more thing — test this on long pages (5,000+ words) in Safari on iOS. The elastic overscroll at the top of the page can briefly push scrollY to negative values, which would clamp your progress bar to a negative width and cause a flicker. The Math.max(0, ...) guard in the hook above prevents that. Keep it.
FAQ
Yes, but add the 'use client' directive at the top — the hook uses useEffect and window, which only exist on the client. Wrap in a <Suspense> boundary if you need server-side rendering.
Usually it's floating-point precision on iOS, where scrollY + clientHeight can equal scrollHeight - 0.5px. Add Math.min(Math.round(pct), 100) to your calculation to clamp it cleanly.
Add a title attribute to each anchor link so screen readers announce the destination, and confirm keyboard focus order is logical top-to-bottom. The aria-current='true' attribute on the active dot is also a nice touch.
The new CSS animation-timeline: scroll() property (fully supported since Chrome 115) lets you do a pure-CSS top bar with zero JS. It's great for static sites — but for the circle and sidebar dot variants, you still need JS.