EmpireUI
Get Pro
← Blog7 min read#scroll-progress-indicator#next-js#react-hooks

Scroll Progress Indicator: Reading Bar in Next.js App Router

Build a scroll progress indicator reading bar in Next.js App Router with React hooks, Tailwind CSS v4, and zero dependencies. Lightweight, accessible, and theme-aware.

Code editor on a dark monitor showing a React component being developed

Why Scroll Progress Indicators Still Matter

Honestly, most reading bars are implemented wrong. They flicker on re-render, break with sticky headers, or fire scroll events so aggressively they kill page performance on mid-range Android devices. That's the real reason this component keeps coming up in codebases — it looks trivial but there are a dozen subtle things to get right.

A scroll progress indicator is a fixed bar that fills from left to right as the user scrolls down a page. You'll find it on news sites, documentation portals, and long-form blog posts. It communicates context: how far through the content the reader is. The thing is, when it's janky, it actively hurts perceived quality more than having no indicator at all.

This guide walks through building one properly for Next.js 14+ App Router. No external libraries. Just a single client component, a useScrollProgress hook, and about 40 lines of Tailwind CSS. We'll cover the scroll event pitfall, requestAnimationFrame throttling, and why you want will-change: transform instead of animating width.

The useScrollProgress Hook

The hook is the core of this component. It listens to the window scroll event and computes a value between 0 and 1 representing how far the user has scrolled through the document. The naive implementation just reads window.scrollY directly — that's fine for desktop, but on mobile it fires 60+ times per second during a flick gesture and your React state updates won't keep up.

The right approach is to debounce via requestAnimationFrame. You schedule a frame, compute the progress, update state, then clear the pending frame reference. This way you process at most one update per paint cycle regardless of how fast scroll events fire.

// hooks/useScrollProgress.ts
'use client'
import { useState, useEffect, useRef } from 'react'

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

  useEffect(() => {
    const handleScroll = () => {
      if (rafRef.current !== null) return

      rafRef.current = requestAnimationFrame(() => {
        const scrollTop = window.scrollY
        const docHeight =
          document.documentElement.scrollHeight -
          document.documentElement.clientHeight

        const pct = docHeight > 0 ? scrollTop / docHeight : 0
        setProgress(Math.min(1, Math.max(0, pct)))
        rafRef.current = null
      })
    }

    window.addEventListener('scroll', handleScroll, { passive: true })
    handleScroll() // initialise on mount

    return () => {
      window.removeEventListener('scroll', handleScroll)
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current)
      }
    }
  }, [])

  return progress
}

Building the ReadingBar Component

The component itself is intentionally small. It takes the progress value from the hook and applies it as a CSS transform — specifically scaleX() on a full-width bar. This is the critical trick: if you animate width from 0% to 100%, the browser has to recalculate layout on every frame. If you use transform: scaleX(), the compositor thread handles it without touching layout. 60fps, no jank.

Add will-change: transform to the element so the browser can promote it to its own compositor layer ahead of time. And set transform-origin: left so the scale happens from the left edge inward. Without that, the bar would shrink toward the center.

// components/ReadingBar.tsx
'use client'
import { useScrollProgress } from '@/hooks/useScrollProgress'

export function ReadingBar() {
  const progress = useScrollProgress()

  return (
    <div
      aria-hidden="true"
      className="fixed top-0 left-0 right-0 z-50 h-[3px] bg-transparent pointer-events-none"
    >
      <div
        className="h-full bg-violet-500 origin-left will-change-transform"
        style={{
          transform: `scaleX(${progress})`,
          transition: 'transform 80ms linear',
        }}
      />
    </div>
  )
}

Wiring It Into Next.js App Router

App Router's layout system makes placement dead simple. Drop the ReadingBar into your root layout.tsx right after the opening <body> tag. Because it's a Client Component and root layout is a Server Component by default, the 'use client' directive inside the component file handles the boundary — you don't need to wrap anything.

One thing to watch: don't put the reading bar inside a scrollable <div> container. It has to track window.scrollY, not an inner element's scroll position. If your layout uses overflow-y: auto on a wrapper div instead of letting the body scroll (common in dashboard layouts), you'll need to adapt the hook to target that element via a ref instead.

For sites with a fixed navigation header — say 64px tall — the bar should sit at top-0 and layer above everything else with z-50 or higher. Check that your nav's z-index doesn't accidentally cover it. Also consider if your theme toggle implementation affects the bar color in dark mode — you may want to swap bg-violet-500 for a CSS variable tied to your theme.

If you want a more minimal variant with no transition (useful for very long documentation pages where the 80ms delay feels sluggish), drop the transition style entirely and let requestAnimationFrame handle the smoothing naturally. The result is crisper.

Styling Options: Tailwind v4 and Custom Gradients

The single-color bar works fine, but a gradient makes it feel more polished without any JavaScript overhead. With Tailwind v4.0.2 you can compose arbitrary gradient stops inline using the new bg-linear-* utilities, or fall back to an inline style for complex cases.

A three-stop gradient from violet to indigo to sky looks great on both light and dark backgrounds. Set the bar height to 3px — not 2px, not 4px. Three pixels is the right balance between visible and unobtrusive. Trust me on this one.

// Gradient variant — replace the inner div's className
<div
  className="h-full origin-left will-change-transform"
  style={{
    background: 'linear-gradient(90deg, #7c3aed 0%, #4f46e5 50%, #0ea5e9 100%)',
    transform: `scaleX(${progress})`,
    transition: 'transform 80ms linear',
    boxShadow: '0 0 8px rgba(124, 58, 237, 0.6)',
  }}
/>

Accessibility and Reduced Motion

The element should always carry aria-hidden="true". It's decorative — screen reader users don't benefit from knowing the scroll percentage, and announcing it would be noise. Skip the ARIA entirely rather than trying to make it 'accessible' in a way that just creates extra announcements.

Reduced motion is the one place where you might want to disable the component entirely, not just remove the transition. Some users with vestibular disorders find progress bars that move constantly uncomfortable — not because of the speed, but the persistent motion at the edge of vision. Checking prefers-reduced-motion via a media query and returning null from the component is the respectful call.

// Reduced motion guard
import { useEffect, useState } from 'react'

function usePrefersReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false)

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
    setPrefersReduced(mq.matches)
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches)
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  return prefersReduced
}

// In ReadingBar.tsx
export function ReadingBar() {
  const progress = useScrollProgress()
  const reducedMotion = usePrefersReducedMotion()

  if (reducedMotion) return null

  // ...rest of component
}

Combining With Other Empire UI Components

The reading bar pairs naturally with long-form content layouts. If you're building a blog or docs site with Empire UI, you might already be using animated tabs to organize content or cards stacks for related articles. The reading bar adds one more layer of polish that makes the experience feel cohesive.

What happens when a user switches tabs mid-article? The progress bar stays at whatever position it was when they left. That's fine — don't reset it on tab visibility change. Resetting on every tab switch is annoying. Users return to where they left off; the bar should reflect that.

One pattern worth considering: combine the scroll progress value with a percentage display in the corner for long technical documentation. Something like {Math.round(progress * 100)}% in a small fixed badge bottom-right. It gives power users precise feedback without cluttering the header area.

Testing and Edge Cases

Short pages are the main edge case. If the document height equals the viewport height — no scrollable content — docHeight is 0 and you'd get a division-by-zero. The docHeight > 0 guard in the hook handles this, returning 0 progress. You might want to hide the bar entirely when progress === 0 on mount, to avoid showing an empty bar track on pages that don't scroll.

Dynamic content is trickier. If you load more content via infinite scroll or lazy expansion — the kind of thing you'd see with a marquee component showing live content — the document height changes after mount. You'll want to also listen to a ResizeObserver on document.body and recompute the denominator when it changes. The scroll handler alone won't catch that.

Finally, test on iOS Safari. The bounce effect at the top and bottom of the page can produce negative scrollY values or values greater than docHeight. The Math.min(1, Math.max(0, pct)) clamp in the hook handles both. Without that clamp, the bar would overshoot its container and break the gradient appearance.

FAQ

Why use `transform: scaleX()` instead of animating `width` for the progress bar?

Animating width triggers layout recalculation on every frame because the browser has to recompute how the element affects surrounding content. transform: scaleX() runs on the compositor thread and bypasses layout entirely, giving you consistent 60fps performance even on lower-end devices. Add will-change: transform to hint the browser to promote the element to its own layer.

How do I adapt the hook if my page uses a scrollable div instead of window scroll?

Pass a React.RefObject<HTMLElement> to the hook and replace window.addEventListener('scroll', ...) with ref.current.addEventListener('scroll', ...). Compute the denominator as element.scrollHeight - element.clientHeight and read element.scrollTop instead of window.scrollY.

Does this work with Next.js Parallel Routes or Intercepting Routes?

Yes, as long as the ReadingBar is placed in the root layout rather than inside a route-specific layout. If it's inside a parallel route slot, it only tracks scroll within that slot's render tree, which is usually not what you want. Root layout placement is the safest option.

Can I use this with React Server Components directly?

No. The hook uses useEffect and useState, which are client-only. The component file needs 'use client' at the top. That directive doesn't prevent the rest of your layout from being a Server Component — Next.js handles the boundary automatically.

The bar flickers briefly on initial page load. How do I fix that?

Initialize the progress state by calling the calculation once synchronously in a useLayoutEffect instead of useEffect, or just accept 0 as the initial value and add a short opacity-0 to opacity-100 fade with a 200ms delay on mount. The flicker is usually the state going from 0 to the real scroll position on hydration.

How do I reset the progress bar on route changes in App Router?

You generally don't need to. When the route changes and the new page renders, the window scrolls back to the top (default Next.js behavior), so scrollY becomes 0 and the bar resets automatically on the next scroll event. If your layout preserves scroll position between routes, call window.scrollTo(0, 0) in a useEffect that watches usePathname().

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

Read next

Avatar Upload in React: Crop, Preview, S3 Upload FlowStepper Progress Form: Linear Flow with ValidationScroll Progress Animation: Reading Bar and Section IndicatorsReact Server Actions: Complete Guide for Next.js App Router