typescript
59 lines · 8 steps
Building a scroll-spy hook in React
A custom hook uses IntersectionObserver to track which page section is in view and highlights the matching nav link.
Explained by
highlit
1import { useEffect, useState } from "react";
2
3interface Section {
4 id: string;
5 label: string;
6}
7
8export function useScrollSpy(sections: Section[], offset = 96) {
9 const [activeId, setActiveId] = useState(sections[0]?.id ?? "");
10
11 useEffect(() => {
12 const observer = new IntersectionObserver(
13 (entries) => {
14 const visible = entries
15 .filter((entry) => entry.isIntersecting)
16 .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
17
18 if (visible.length > 0) {
19 setActiveId(visible[0].target.id);
20 }
21 },
22 {
23 rootMargin: `-${offset}px 0px -55% 0px`,
24 threshold: [0.1, 0.5, 1],
25 }
26 );
27
28 const nodes = sections
29 .map((section) => document.getElementById(section.id))
30 .filter((node): node is HTMLElement => node !== null);
31
32 nodes.forEach((node) => observer.observe(node));
33 return () => observer.disconnect();
34 }, [sections, offset]);
35
36 return activeId;
37}
38
39export function ScrollSpyNav({ sections }: { sections: Section[] }) {
40 const activeId = useScrollSpy(sections);
41
42 return (
43 <nav aria-label="On this page">
44 <ul>
45 {sections.map((section) => (
46 <li key={section.id}>
47 <a
48 href={`#${section.id}`}
49 aria-current={activeId === section.id ? "location" : undefined}
50 className={activeId === section.id ? "nav-link active" : "nav-link"}
51 >
52 {section.label}
53 </a>
54 </li>
55 ))}
56 </ul>
57 </nav>
58 );
59}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1IntersectionObserver reports visibility changes efficiently, avoiding manual scroll-event math.
- 2Wrapping observer setup and teardown in useEffect keeps subscriptions in sync with props.
- 3Deriving aria-current from active state makes the current section clear to assistive tech.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
typescript
import { Injectable, Scope, Inject, NotFoundException } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; import { DataSource } from 'typeorm';
Per-tenant database connections in NestJS
multi-tenancy
connection-pooling
dependency-injection
Advanced
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-scroll-spy-hook-in-react-explained-typescript-1337/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.