EmpireUI
Get Pro
← Blog7 min read#scroll-animation#reading-progress#react-hooks

Scroll Progress Animation: Reading Bar and Section Indicators

Build a scroll progress reading bar and section indicators in React with Tailwind v4.0.2. Real code, no fluff — just the patterns that actually work.

Code editor showing scroll animation CSS on a dark background with blue highlights

Why Scroll Progress Animations Actually Matter

Honestly, most developers underestimate how much a reading progress bar affects time-on-page. It's not decorative fluff — it's a signal. Users know how far they've scrolled, which means they're less likely to bail halfway through your article or documentation page.

The pattern has two main forms. A top-mounted horizontal reading bar that fills left-to-right as the user scrolls down. And section indicators — usually a vertical dot-nav on the side — that light up as the user moves through distinct content regions. Both are worth understanding separately because they solve different problems.

You'll see both patterns all over technical blogs, long-form marketing pages, and documentation sites. The reading bar is passive feedback. The section nav is active navigation. Getting them right in React without a dependency explosion is the whole point of this article.

Calculating Scroll Progress with a React Hook

The math is straightforward: divide window.scrollY by document.documentElement.scrollHeight - window.innerHeight. Multiply by 100 and you have a percentage from 0 to 100. That's it. The complexity is in wiring it up without nuking performance.

Here's a custom hook that throttles via requestAnimationFrame so you're not triggering 60 re-renders per second on a fast scroll:

import { useState, useEffect, useRef } from 'react';

export function useScrollProgress() {
  const [progress, setProgress] = useState(0);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    const onScroll = () => {
      if (rafRef.current) return;
      rafRef.current = requestAnimationFrame(() => {
        const scrollTop = window.scrollY;
        const docHeight =
          document.documentElement.scrollHeight - window.innerHeight;
        const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
        setProgress(Math.min(100, Math.max(0, pct)));
        rafRef.current = null;
      });
    };

    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  return progress;
}

The passive: true flag on the event listener is non-negotiable. Without it, the browser has to wait for your handler to potentially call preventDefault() before it can scroll. That kills jank-free scrolling immediately. Always set it.

Building the Reading Progress Bar Component

With the hook in place, the component itself is almost embarrassingly simple. A fixed-position div at the top of the viewport, width driven by the progress value, height of 3px or 4px. The only interesting decision is the color and whether you want a gradient.

Here's a complete component using Tailwind v4.0.2 utility classes with an inline style for the dynamic width:

import { useScrollProgress } from '@/hooks/useScrollProgress';

export function ReadingProgressBar() {
  const progress = useScrollProgress();

  return (
    <div
      className="fixed top-0 left-0 z-50 h-[3px] origin-left
                 bg-gradient-to-r from-violet-500 via-fuchsia-500 to-pink-500
                 transition-[width] duration-75 ease-out"
      style={{ width: `${progress}%` }}
      role="progressbar"
      aria-valuenow={Math.round(progress)}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-label="Reading progress"
    />
  );
}

A few notes. The transition-[width] with duration-75 adds a tiny lag that smooths out scroll jitter without making it feel sluggish. Go above 150ms and it starts trailing behind the actual scroll position in a way that feels broken. The origin-left class keeps the bar growing from the left edge rather than from center.

The role="progressbar" with aria attributes is worth adding. Screen readers can announce reading position, which is a legitimate accessibility win. It's two lines. Do it.

Section Indicators with IntersectionObserver

Section indicators are a different animal. You're not tracking a global scroll percentage — you're tracking which content region is currently in view. That's exactly what IntersectionObserver was built for.

The approach: each section you want to track gets a data-section attribute and a unique id. The observer watches all of them with a threshold of 0.3, meaning a section triggers as active when 30% of it is visible. You store the active section id in state and use that to highlight the corresponding dot in your nav.

import { useState, useEffect } from 'react';

export function useSectionObserver(sectionIds: string[]) {
  const [activeId, setActiveId] = useState<string | null>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
          }
        });
      },
      { threshold: 0.3, rootMargin: '-10% 0px -60% 0px' }
    );

    sectionIds.forEach((id) => {
      const el = document.getElementById(id);
      if (el) observer.observe(el);
    });

    return () => observer.disconnect();
  }, [sectionIds]);

  return activeId;
}

The rootMargin: '-10% 0px -60% 0px' is doing real work here. It shrinks the effective intersection area so that a section only registers as active when it's actually in the upper portion of the viewport — not just barely peeking in from the bottom. Tune this value to match your layout.

Building the Section Dot Navigation

The visual component for section nav is a vertical list of dots, typically fixed to the right side of the viewport. Each dot corresponds to one section. The active dot gets a larger size or different color. Clicking a dot smooth-scrolls to the section. That's the whole spec.

const sections = [
  { id: 'intro', label: 'Introduction' },
  { id: 'setup', label: 'Setup' },
  { id: 'usage', label: 'Usage' },
  { id: 'api', label: 'API Reference' },
];

export function SectionDotNav() {
  const activeId = useSectionObserver(sections.map((s) => s.id));

  const scrollTo = (id: string) => {
    document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
  };

  return (
    <nav
      className="fixed right-6 top-1/2 -translate-y-1/2 z-40
                 flex flex-col gap-[8px]"
      aria-label="Page sections"
    >
      {sections.map((section) => (
        <button
          key={section.id}
          onClick={() => scrollTo(section.id)}
          aria-label={`Go to ${section.label}`}
          className={`rounded-full transition-all duration-200
            ${
              activeId === section.id
                ? 'w-3 h-3 bg-violet-500 scale-110'
                : 'w-2 h-2 bg-white/30 hover:bg-white/60'
            }`
          }
        />
      ))}
    </nav>
  );
}

Notice the gap-[8px] — arbitrary value in Tailwind v4.0.2 syntax. The active dot uses scale-110 instead of just swapping width/height so the transition animates cleanly via CSS transform rather than layout recalculation. That's the kind of micro-decision that separates smooth from janky.

Want to add a tooltip showing the section name on hover? Wrap the button in a relative container and add an absolutely-positioned span with opacity-0 group-hover:opacity-100. The group utility handles the hover state without any JavaScript.

Combining Both Patterns Without Layout Fights

Running both a reading bar and section dots on the same page is fine — they don't conflict because they're both fixed-position elements outside the document flow. But there are a couple of gotchas worth calling out.

First, the reading bar measures total document scroll. If your page has a sticky header of say 64px, window.innerHeight already accounts for that correctly, so no adjustment needed. But if you have a custom scroll container (like a div with overflow-y: auto instead of body scroll), both the bar logic and IntersectionObserver need to target that element specifically — not window. That's a common setup in SPA layouts with fixed sidebars.

Second, think about mobile. A 3px reading bar at the very top of the viewport on mobile often gets clipped under the browser chrome or conflicts with PWA safe areas. Add env(safe-area-inset-top) padding if you're targeting installed apps. The section dot nav should probably hide below a certain breakpoint too — there's not enough horizontal space on a 375px screen for both content and a side dot rail. Just hidden lg:flex does the job.

If you're already using animated backgrounds like aurora effects or particle systems on the same page, check your z-index stack carefully. Fixed elements can silently stack in unexpected order if you haven't set explicit z-index values.

Performance Considerations and CSS-Only Alternatives

How bad is the JavaScript overhead here, actually? The RAF-throttled scroll hook fires roughly once per frame at 60fps during active scrolling. Each call does integer division, a setState, and a style update. On a mid-range Android device this is fine. You're not doing DOM queries in the hot path.

That said, there's a pure CSS reading bar trick using animation-timeline: scroll() — part of CSS Scroll-Driven Animations that shipped in Chrome 115 and is now in Firefox 130+. No JavaScript at all. The syntax looks like this:

@keyframes grow-bar {
  from { width: 0%; }
  to   { width: 100%; }
}

.reading-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: linear-gradient(90deg, #8b5cf6, #ec4899);
  animation: grow-bar linear;
  animation-timeline: scroll(root block);
}

No event listeners, no state, no React. Browser support in late 2026 is good enough for most production use — just check your target audience. Safari added support in Safari 18. If you need IE-era compatibility (you don't, but just in case), the React hook approach is the safe fallback. For anything styling-adjacent, also worth reading what glassmorphism is if you're building a docs site with frosted nav elements, or check out theme toggle patterns in React if your progress bar needs to adapt between light and dark modes.

Wiring It Into an Empire UI Page Layout

Empire UI doesn't ship a pre-built scroll progress component out of the box right now, but the hooks and component patterns above drop straight into any Empire UI page layout without modification. The library's component structure uses standard React context and Tailwind class composition, so there's no conflict.

The recommended pattern: put <ReadingProgressBar /> inside your root layout component, directly after the opening <body> tag equivalent. The <SectionDotNav /> goes inside the page component itself since it needs to know which sections exist on that specific page. Don't try to make it global — that creates a prop-threading headache for no benefit.

Is there a version of this that works with React Server Components? Yes — both components can be marked as Client Components with 'use client' while your page layout stays a Server Component. The scroll state is inherently client-side, so there's no way around that directive. That's not a limitation, it's just the right model.

FAQ

Does the CSS scroll-driven animation approach work without any JavaScript?

Yes. animation-timeline: scroll(root block) in Chrome 115+, Firefox 130+, and Safari 18+ lets you animate a reading bar purely in CSS with no event listeners or React state. For older browsers you'll need the JavaScript RAF-throttled hook as a fallback.

How do I handle the progress bar on pages with a custom scroll container instead of body scroll?

Replace window.scrollY with containerEl.scrollTop and document.documentElement.scrollHeight - window.innerHeight with containerEl.scrollHeight - containerEl.clientHeight. You'll also need to attach the event listener to the container element, not window. IntersectionObserver accepts a root option for the same reason.

What's the right IntersectionObserver threshold for section detection?

A threshold of 0.3 combined with rootMargin of '-10% 0px -60% 0px' works well for most long-form content. The rootMargin shrinks the detection zone so sections activate when they're in the upper viewport area rather than just barely visible. Tune the bottom margin (the -60% value) based on your average section height.

Why use `scale-110` instead of changing width/height for the active dot state?

CSS transforms like scale don't trigger layout recalculation — they run on the compositor thread. Changing width or height forces the browser to recalculate layout for the element and potentially its siblings. For a tiny dot nav this difference is negligible, but scale is the correct habit for smooth 60fps transitions.

Should I hide the section dot nav on mobile?

Yes, generally. On viewports under 768px there's not enough horizontal margin to display a dot rail without overlapping content. Use Tailwind's hidden lg:flex on the nav container. You can optionally show a horizontal dot indicator at the bottom of the viewport instead, but that's a separate component decision.

Will the reading bar cause hydration mismatches in Next.js?

Not if the component is marked 'use client' and initializes progress state to 0. The server renders width 0%, the client hydrates with 0%, and then the scroll listener updates it. There's no mismatch. Avoid reading window.scrollY during the initial render — only inside useEffect.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Scroll-Driven Animations: No JavaScript, Full Browser SupportGSAP + React: Production-Ready Animation Without Side EffectsScroll Progress Indicator: Reading Bar in Next.js App RouterTailwind Animation Utilities: Built-In Classes and Custom Keyframes