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

Walkthrough

Space play step click any line
Three takeaways
  1. 1IntersectionObserver reports visibility changes efficiently, avoiding manual scroll-event math.
  2. 2Wrapping observer setup and teardown in useEffect keeps subscriptions in sync with props.
  3. 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.

Building a scroll-spy hook in React — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code