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.
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.
offsetshould 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-labelon 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.hashexists on mount, run a one-time smooth scroll to that id using your offset.
- If
- 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.
- If you load content lazily, the section positions move. The scroll listener approach adapts automatically; with IO, consider re-observing or calling
- 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
rootto that container.
- If your sections live in a scrollable div, bind listeners to that element and compute offsets relative to its scroll position. For IO, set
- Server-side rendering (Next.js)
- Mark the page or component as a client component (
'use client'). - Access
windowanddocumentinsideuseEffectonly.
- Mark the page or component as a client component (
- Reduced motion
- Respect users with
prefers-reduced-motion: reduceby disabling smooth scrolling and jumping instantly.
- Respect users with
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: stickyfor 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
getBoundingClientRectand scroll positions. - Integration test with Playwright or Cypress: visit the page, click each nav link, assert URL hashes and
aria-currentchanges; 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
Building an Accessible, Responsive React Collapsible Sidebar Navigation
Build a fast, accessible, responsive React collapsible sidebar with TypeScript, ARIA, keyboard support, and persisted state—no UI library required.
Build a Production‑Ready React Avatar User Profile Component
Build an accessible, flexible React avatar component with initials fallback, status badges, groups, and TypeScript—optimized for performance and a11y.
Build an Accessible React Color Picker: A Practical Guide
Build a fast, accessible React color picker from scratch: HSV logic, sliders, popover, keyboard/mouse support, CSS variables, tests, and library options.