Build a Rock-Solid React Scroll Spy Navigation with Hooks and TypeScript

Build a robust React scroll spy navigation with hooks, a11y, smooth scrolling, SSR tips, and TypeScript—plus an optional IntersectionObserver version.

ASOasis
8 min read
Build a Rock-Solid React Scroll Spy Navigation with Hooks and TypeScript

Image used for representation purposes only.

Overview

A scroll spy highlights the navigation link that corresponds to the section currently visible in the viewport. It improves wayfinding, reduces cognitive load in long documents, and enables deep-linking to specific sections. In React, you can build a scroll spy that is fast, accessible, and framework-friendly using a small hook and a lightweight navigation component.

This article walks through:

  • Designing a robust scroll spy for real-world layouts (fixed headers, sticky sidebars, dynamic content)
  • Building a reusable React hook in TypeScript
  • Wiring an accessible navigation that updates aria-current
  • Handling smooth scrolling, hash syncing, SSR, and performance pitfalls

Design choices: IntersectionObserver vs. scroll listeners

You have two solid options:

  • IntersectionObserver

    • Pros: Runs off the main thread, very efficient, easy to track multiple sections.
    • Cons: Trickier to tune for fixed headers and edge cases like the very bottom of the page.
  • Scroll listener + requestAnimationFrame

    • Pros: Very predictable; easy to factor header offsets and container-specific scrolling.
    • Cons: You must throttle; potentially more main-thread work.

In practice, both can be excellent. Below, we implement a compact scroll listener + rAF solution because it is predictable and simple to adapt to edge cases. We then note how to swap to IntersectionObserver if you prefer.

The hook: useScrollSpy (TypeScript)

The hook accepts an ordered list of section ids (top-to-bottom) and returns the activeId. It supports a top offset (e.g., your fixed header height) so the highlight changes when a section crosses the adjusted threshold.

// useScrollSpy.ts
import { useEffect, useRef, useState } from 'react'

type UseScrollSpyOptions = {
  offset?: number // pixels from top of viewport where a section is considered 'active'
}

export function useScrollSpy(sectionIds: string[], options: UseScrollSpyOptions = {}) {
  const { offset = 0 } = options
  const [activeId, setActiveId] = useState<string | null>(sectionIds[0] ?? null)
  const ticking = useRef<number | null>(null)

  useEffect(() => {
    if (!sectionIds.length) return

    const getY = () => window.scrollY || window.pageYOffset

    const measure = () => {
      ticking.current = null

      // If we are at (or extremely near) the bottom, snap to the last section
      const atBottom = Math.ceil(window.innerHeight + getY()) >= Math.floor(document.documentElement.scrollHeight)
      if (atBottom) {
        setActiveId(sectionIds[sectionIds.length - 1])
        return
      }

      let bestId: string | null = null
      let bestDistance = Number.POSITIVE_INFINITY

      for (const id of sectionIds) {
        const el = document.getElementById(id)
        if (!el) continue
        const rect = el.getBoundingClientRect()
        // Distance from the adjusted top. Negative means the section top is above the threshold.
        const distance = Math.abs(rect.top - offset)
        // Prefer sections that are above or near the offset; bias with an additional penalty if far below
        const bias = rect.top > offset ? 100000 : 0 // push future sections down the ranking
        const score = distance + bias
        if (score < bestDistance) {
          bestDistance = score
          bestId = id
        }
      }

      if (bestId) setActiveId(bestId)
    }

    const onScroll = () => {
      if (ticking.current == null) {
        ticking.current = window.requestAnimationFrame(measure)
      }
    }

    const onResize = onScroll

    // Initial run
    measure()

    window.addEventListener('scroll', onScroll, { passive: true })
    window.addEventListener('resize', onResize)

    return () => {
      if (ticking.current != null) cancelAnimationFrame(ticking.current)
      window.removeEventListener('scroll', onScroll)
      window.removeEventListener('resize', onResize)
    }
  }, [sectionIds.join('|'), offset])

  return activeId
}

Notes:

  • The bottom-of-page snap ensures the last section becomes active when users reach the end.
  • The bias favors sections the user has actually scrolled into (above the offset) over those still below.
  • offset should match your fixed header height plus a small margin.

The navigation: ScrollSpyNav

We will render a vertical list of anchor links. The current section receives aria-current='true' and a visual state.

// ScrollSpyNav.tsx
import React from 'react'

export type NavItem = {
  id: string
  label: string
}

type Props = {
  items: NavItem[]
  activeId: string | null
  onLinkClick?: (id: string, event: React.MouseEvent<HTMLAnchorElement>) => void
  ariaLabel?: string
}

export function ScrollSpyNav({ items, activeId, onLinkClick, ariaLabel = 'Page sections' }: Props) {
  return (
    <nav className='scrollspy-nav' aria-label={ariaLabel}>
      <ul>
        {items.map(item => {
          const isActive = item.id === activeId
          return (
            <li key={item.id}>
              <a
                href={`#${item.id}`}
                aria-current={isActive ? 'true' : undefined}
                onClick={e => onLinkClick?.(item.id, e)}
              >
                {item.label}
              </a>
            </li>
          )
        })}
      </ul>
    </nav>
  )
}

Smooth scrolling and hash syncing

Use native smooth scrolling via CSS, and optionally enhance with focus management for accessibility on click:

// useSmoothScroll.ts
import { useCallback } from 'react'

export function useSmoothScroll(offset = 0) {
  return useCallback((id: string) => {
    const el = document.getElementById(id)
    if (!el) return

    // Ensure focusability for screen-reader and keyboard users
    const prevTabIndex = el.getAttribute('tabindex')
    if (prevTabIndex === null) el.setAttribute('tabindex', '-1')

    const y = el.getBoundingClientRect().top + window.scrollY - offset
    window.history.replaceState(null, '', `#${id}`)
    window.scrollTo({ top: y, behavior: 'smooth' })

    // Move programmatic focus without jank
    setTimeout(() => {
      el.focus({ preventScroll: true })
      if (prevTabIndex === null) el.removeAttribute('tabindex')
    }, 0)
  }, [offset])
}

Putting it together in a page

// Page.tsx
'use client'
import React from 'react'
import { useScrollSpy } from './useScrollSpy'
import { ScrollSpyNav, NavItem } from './ScrollSpyNav'
import { useSmoothScroll } from './useSmoothScroll'

const sections: NavItem[] = [
  { id: 'intro', label: 'Introduction' },
  { id: 'setup', label: 'Setup' },
  { id: 'implementation', label: 'Implementation' },
  { id: 'a11y', label: 'Accessibility' },
  { id: 'advanced', label: 'Advanced tips' },
]

const HEADER_HEIGHT = 64

export default function Page() {
  const activeId = useScrollSpy(sections.map(s => s.id), { offset: HEADER_HEIGHT + 8 })
  const smoothScroll = useSmoothScroll(HEADER_HEIGHT + 8)

  return (
    <div className='layout'>
      <header className='site-header' style={{ height: HEADER_HEIGHT }}>Docs</header>
      <aside className='sidebar'>
        <ScrollSpyNav
          items={sections}
          activeId={activeId}
          onLinkClick={(id, e) => {
            e.preventDefault()
            smoothScroll(id)
          }}
        />
      </aside>
      <main className='content'>
        <section id='intro'>
          <h2>Introduction</h2>
          <p>Why scroll spy matters and what you will build.</p>
        </section>
        <section id='setup'>
          <h2>Setup</h2>
          <p>Install React, TypeScript, and your bundler of choice.</p>
        </section>
        <section id='implementation'>
          <h2>Implementation</h2>
          <p>Hook details, nav wiring, and smooth scrolling.</p>
        </section>
        <section id='a11y'>
          <h2>Accessibility</h2>
          <p>Keyboard nav, focus, and ARIA best practices.</p>
        </section>
        <section id='advanced'>
          <h2>Advanced tips</h2>
          <p>SSR, dynamic content, and container-based spying.</p>
        </section>
      </main>
    </div>
  )
}

Minimal CSS

/* app.css */
:root {
  --header-h: 64px;
  --gap: 16px;
  --accent: #5b8cff;
}

html { scroll-behavior: smooth; }

.layout {
  display: grid;
  grid-template-columns: 240px 1fr;
  grid-template-areas:
    'header header'
    'sidebar content';
}

.site-header {
  grid-area: header;
  position: sticky;
  top: 0;
  display: flex;
  align-items: center;
  padding: 0 var(--gap);
  background: white;
  border-bottom: 1px solid #e6e6e6;
  z-index: 10;
}

.sidebar {
  grid-area: sidebar;
  position: sticky;
  top: calc(var(--header-h) + var(--gap));
  align-self: start;
  height: calc(100vh - var(--header-h) - var(--gap));
  overflow: auto;
  padding: var(--gap);
}

.scrollspy-nav ul { list-style: none; margin: 0; padding: 0; }
.scrollspy-nav a {
  display: block;
  padding: 6px 10px;
  color: #3a3a3a;
  text-decoration: none;
  border-left: 2px solid transparent;
  border-radius: 4px;
}
.scrollspy-nav a:hover { background: #f6f7fb; }
.scrollspy-nav a[aria-current='true'] {
  color: #0f1e49;
  font-weight: 600;
  border-left-color: var(--accent);
  background: #eef3ff;
}

.content { grid-area: content; padding: 24px 32px; }

/* Ensure headings stop below the header when jumped-to */
section { scroll-margin-top: calc(var(--header-h) + var(--gap)); }

Accessibility checklist

  • Use aria-label on the nav to describe the landmark (e.g., ‘Page sections’).
  • Mark the active link with aria-current='true'.
  • Ensure section headings become focusable and receive programmatic focus on navigation; otherwise, keyboard and screen-reader users may lose their place.
  • Maintain logical DOM order: navigation before main content for keyboard tab order, or provide a ‘Skip to content’ link.

Swapping to IntersectionObserver (optional)

If you want an IO-based version, observe each section and track which ones intersect a virtual top band using rootMargin:

// useScrollSpyIO.ts (sketch)
import { useEffect, useState } from 'react'

type Opts = { offset?: number; threshold?: number | number[] }
export function useScrollSpyIO(ids: string[], { offset = 0, threshold = [0, 0.25, 0.5] }: Opts = {}) {
  const [activeId, setActiveId] = useState<string | null>(ids[0] ?? null)

  useEffect(() => {
    const els = ids.map(id => document.getElementById(id)).filter(Boolean) as HTMLElement[]
    if (!els.length) return

    const io = new IntersectionObserver((entries) => {
      // Choose the visible section closest to the top
      const candidates = entries
        .filter(e => e.isIntersecting)
        .map(e => ({ id: (e.target as HTMLElement).id, top: (e.target as HTMLElement).getBoundingClientRect().top }))
        .sort((a, b) => a.top - b.top)

      if (candidates.length) setActiveId(candidates[0].id)
    }, { root: null, rootMargin: `-${offset}px 0px -70% 0px`, threshold })

    els.forEach(el => io.observe(el))
    return () => io.disconnect()
  }, [ids.join('|'), offset, JSON.stringify(threshold)])

  return activeId
}

Adjust rootMargin so the top band sits below your fixed header, and use a large negative bottom margin (e.g., -70%) to keep the active zone narrow.

Handling real-world edge cases

  • Deep links on load
    • If location.hash exists on mount, run a one-time smooth scroll to that id using your offset.
  • Dynamic content and images that shift layout
    • If you load content lazily, the section positions move. The scroll listener approach adapts automatically; with IO, consider re-observing or calling io.takeRecords() after content settles.
  • Multiple scroll containers
    • If your sections live in a scrollable div, bind listeners to that element and compute offsets relative to its scroll position. For IO, set root to that container.
  • Server-side rendering (Next.js)
    • Mark the page or component as a client component ('use client').
    • Access window and document inside useEffect only.
  • Reduced motion
    • Respect users with prefers-reduced-motion: reduce by disabling smooth scrolling and jumping instantly.

Example:

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

Performance tips

  • Keep the section list small and ordered; avoid querying the DOM repeatedly.
  • Throttle using requestAnimationFrame (already built into the hook above).
  • Avoid heavy work inside the scroll handler; only measure what you need.
  • Use CSS position: sticky for the sidebar instead of recalculating positions in JS.
  • If your page has dozens of sections, consider IO to reduce main-thread work.

Testing your scroll spy

  • Unit test the hook logic by mocking getBoundingClientRect and scroll positions.
  • Integration test with Playwright or Cypress: visit the page, click each nav link, assert URL hashes and aria-current changes; then manually scroll and assert again.
  • Keyboard test: Tab to the navigation, use Enter to activate links, ensure focus lands on the heading.

Summary

A high-quality React scroll spy combines predictable logic, accessibility, and small ergonomic touches like snapping at the bottom and respecting fixed headers. With the useScrollSpy hook, a simple ScrollSpyNav, and a few lines of CSS, you get a robust, reusable component that scales from simple docs to complex, dynamic pages. Adapt the approach based on your constraints: the scroll listener variant is easy to reason about, while an IntersectionObserver version can yield even better performance on very long documents.

Related Posts