Sommario attivo durante lo scroll con IntersectionObserver in JavaScript
Quando si pubblica un articolo lungo, un sommario (table of contents) all'inizio della pagina è uno strumento di navigazione molto utile. Il passo successivo, in termini di esperienza utente, è far sì che la voce del sommario corrispondente alla sezione attualmente visualizzata venga evidenziata mentre l'utente scorre la pagina. In questo articolo vediamo come implementare questo comportamento usando la IntersectionObserver API in JavaScript vanilla, organizzando il codice in una classe ES6 riutilizzabile.
L'idea di base
Prima di scrivere codice è importante chiarire cosa vogliamo ottenere e quale strumento usare. La tentazione iniziale è quella di ascoltare l'evento scroll della finestra e calcolare manualmente, ad ogni movimento, quale sezione si trova attualmente sotto un certo punto del viewport. Questo approccio funziona, ma ha due difetti importanti: l'evento scroll si attiva con altissima frequenza e richiede un debounce o un throttle per non degradare le prestazioni, e il calcolo della posizione di ogni sezione tramite getBoundingClientRect() ad ogni frame è oneroso.
La soluzione moderna è la IntersectionObserver API, un meccanismo asincrono fornito dal browser che notifica il nostro codice solo quando un elemento entra o esce da una determinata area di osservazione. È efficiente, dichiarativo e non richiede calcoli manuali.
Come funziona davvero IntersectionObserver
Il punto cruciale, che spesso viene frainteso, è che il callback di un IntersectionObserver non viene invocato continuamente durante lo scroll. Viene invocato soltanto quando lo stato di intersezione di un elemento osservato cambia, cioè quando l'elemento entra o esce dall'area di osservazione. Questo è esattamente ciò che ci serve, ma ci impone di ragionare in termini di transizioni di stato anziché di posizioni continue.
L'area di osservazione è definita da due parametri: root, che indica l'elemento contenitore rispetto al quale calcolare l'intersezione (se vale null, viene usato il viewport del browser), e rootMargin, una stringa con quattro valori (top, right, bottom, left) che permette di restringere o espandere virtualmente l'area di root. Usando valori negativi possiamo creare una banda orizzontale stretta nel viewport e considerare "attiva" una sezione solo quando il suo titolo entra in quella banda.
La struttura della classe
Organizziamo il codice in una classe chiamata ArticleManager. La classe si occupa di tre responsabilità: assegnare un identificatore univoco ad ogni intestazione di secondo livello, costruire dinamicamente il sommario inserendolo nel DOM, e infine attivare un osservatore che aggiorna la voce attiva del sommario in base allo scroll.
Il costruttore riceve un oggetto di configurazione con il selettore CSS dell'elemento che contiene l'articolo e, opzionalmente, il nome della classe da applicare al link attivo del sommario. La classe espone una proprietà tocLinks di tipo Map che associa l'id di ciascuna sezione al rispettivo elemento <a> del sommario, in modo da poter aggiornare la classe attiva in tempo costante senza dover interrogare il DOM ad ogni scroll.
class ArticleManager {
constructor({ rootSelector, activeClass = 'is-active' }) {
this.root = document.querySelector(rootSelector);
if(!this.root) {
throw new Error('Root element not found.');
}
this.activeClass = activeClass;
this.toc = null;
this.head = null;
this.observer = null;
// Mappa che associa l'id di ogni sezione al link del sommario
this.tocLinks = new Map();
this.currentActiveId = null;
this.init();
}
init() {
this.assignAnchors();
this.buildTOC();
this.registerObserver();
}
}
Il metodo init() orchestra le tre fasi nell'ordine corretto: prima si assegnano gli identificatori alle intestazioni, poi si costruisce il sommario che dipende da quegli identificatori, infine si registra l'osservatore che dipende dalle intestazioni esistenti.
Assegnazione degli identificatori
Ogni titolo di sezione deve avere un attributo id univoco, sia per consentire i link interni dal sommario sia per identificare in modo certo la sezione all'interno del callback dell'osservatore. Generiamo gli identificatori in modo automatico usando un indice progressivo.
assignAnchors() {
const headers = this.root.querySelectorAll('h2');
if(headers.length === 0) {
throw new Error('Unable to find h2 elements.');
}
headers.forEach((element, index) => {
// Identificatore univoco basato sull'indice progressivo
element.setAttribute('id', `section-${index + 1}`);
});
}
Se nell'articolo non sono presenti titoli di secondo livello, lanciamo un'eccezione perché in quel caso il sommario non avrebbe alcuna voce e l'intero meccanismo sarebbe inutile. Una scelta alternativa, più permissiva, sarebbe quella di terminare silenziosamente il metodo init(): si tratta di una decisione di design legata al contesto d'uso.
Costruzione del sommario
La costruzione del sommario è il punto in cui costruiamo anche la struttura dati che ci servirà più avanti. Per ogni intestazione creiamo un elemento <li> contenente un <a> con l'href puntato all'identificatore della sezione, e popoliamo contemporaneamente la mappa tocLinks.
buildTOC() {
const headers = this.root.querySelectorAll('h2');
this.toc = document.createElement('nav');
this.toc.className = 'toc';
const title = document.createElement('p');
title.className = 'toc-title';
title.innerText = 'Indice';
this.toc.appendChild(title);
const list = document.createElement('ol');
for(const header of headers) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${header.id}`;
a.textContent = header.innerText;
li.appendChild(a);
list.appendChild(li);
// Si memorizza il riferimento al link per poterlo aggiornare in O(1)
this.tocLinks.set(header.id, a);
}
this.toc.appendChild(list);
this.head = this.root.querySelector('.article-head');
if(!this.head) {
throw new Error('Article header is missing.');
}
this.head.after(this.toc);
}
Notare l'uso di textContent al posto di innerHTML per inserire il testo del link: è una scelta di sicurezza che evita l'interpretazione di eventuale markup contenuto nel titolo, e ha anche il vantaggio di essere leggermente più veloce in quanto non attiva il parser HTML.
La memorizzazione di ogni link nella Map è il dettaglio che rende efficiente l'aggiornamento successivo: senza questa struttura dovremmo ad ogni cambio di sezione attiva interrogare il DOM con un selettore per trovare il link giusto, mentre con la mappa l'accesso è in tempo costante.
Registrazione dell'osservatore
Arriviamo al cuore del meccanismo. Configuriamo l'osservatore con un rootMargin negativo in alto e in basso, in modo da definire una banda orizzontale stretta nel viewport. Una sezione si considera attiva quando il suo titolo entra in questa banda.
registerObserver() {
const options = {
// Si osserva rispetto al viewport del browser
root: null,
// Banda di attivazione tra il 40% e il 45% dall'alto del viewport
rootMargin: '-40% 0px -55% 0px',
threshold: 0
};
const observables = this.root.querySelectorAll('h2');
this.observer = new IntersectionObserver(
(entries) => this.observeSections(entries),
options
);
for(const element of observables) {
this.observer.observe(element);
}
}
I valori del rootMargin meritano una spiegazione approfondita. La sintassi è identica a quella della proprietà CSS margin: i quattro valori indicano top, right, bottom, left. Valori negativi restringono l'area di osservazione, valori positivi la espandono. Con -40% in alto e -55% in basso stiamo dicendo all'osservatore di considerare come area di intersezione solo la fascia compresa tra il 40% e il 45% dall'alto del viewport (perché 100% meno 40% meno 55% lascia il 5%). Quando un titolo h2 entra in questa fascia, l'osservatore ce lo notifica.
La somma delle percentuali verticali deve essere strettamente minore di 100%, altrimenti l'area di osservazione collassa a zero e nessun elemento risulta mai intersecante. Spostando la posizione della banda si modifica il "punto di attivazione" percepito dall'utente: una banda più in alto fa sì che i link si aggiornino prima, una banda più in basso fa sì che si aggiornino più tardi. Il valore di 40% è un compromesso ragionevole che corrisponde a una fascia leggermente sopra il centro del viewport, dove naturalmente si concentra l'attenzione del lettore.
Il parametro threshold: 0 indica che il callback deve essere invocato non appena anche un solo pixel dell'elemento entra nella banda di osservazione. Per i titoli h2, che hanno un'altezza ridotta rispetto al viewport, questa è la scelta corretta.
Il callback dell'osservatore
Il metodo che riceve le notifiche dell'osservatore è il punto in cui si concentra la logica decisionale. Ricordiamo che il callback viene invocato con un array di oggetti IntersectionObserverEntry, uno per ogni elemento osservato il cui stato di intersezione è cambiato dall'ultima invocazione.
observeSections(entries) {
// Si filtrano solo le sezioni attualmente intersecanti la banda
const visible = entries.filter(entry => entry.isIntersecting);
if(visible.length > 0) {
// Se più sezioni sono visibili, si sceglie quella più in alto
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
this.setActiveLink(visible[0].target.id);
}
}
La proprietà isIntersecting di ogni entry indica se l'elemento è entrato o uscito dalla banda. Le entry con isIntersecting falso rappresentano elementi che sono appena usciti, e per la nostra logica non sono direttamente utili: ci interessa sapere chi è dentro la banda, non chi è uscito.
Quando più sezioni sono contemporaneamente visibili nella banda (situazione possibile se due titoli sono molto vicini tra loro), scegliamo quella con la coordinata top minore, cioè quella più in alto sullo schermo. Questa è la scelta più naturale per il lettore, che percepisce come "sezione corrente" quella che ha appena raggiunto durante lo scroll verso il basso.
Una considerazione importante: se nessuna entry è intersecante, non facciamo nulla. Questo significa che la voce attiva resta quella precedente, comportamento desiderabile perché evita "buchi" visivi quando nessun titolo è momentaneamente nella banda di attivazione (per esempio mentre l'utente sta scorrendo all'interno di una sezione molto lunga).
Aggiornamento del link attivo
Il metodo che applica concretamente la classe CSS al link corretto è semplice ma incorpora alcune ottimizzazioni utili.
setActiveLink(id) {
// Se l'identificatore non è cambiato, non si fa nulla
if(this.currentActiveId === id) return;
// Si rimuove la classe dal link precedentemente attivo
if(this.currentActiveId && this.tocLinks.has(this.currentActiveId)) {
this.tocLinks.get(this.currentActiveId).classList.remove(this.activeClass);
}
// Si applica la classe al nuovo link attivo
if(id && this.tocLinks.has(id)) {
this.tocLinks.get(id).classList.add(this.activeClass);
this.currentActiveId = id;
}
}
Il primo controllo evita lavoro inutile quando il callback dell'osservatore notifica un cambiamento ma la sezione attiva è ancora la stessa. Le due interrogazioni alla mappa con has() e get() sono operazioni in tempo costante, quindi l'intero metodo ha complessità costante indipendentemente dal numero di voci del sommario.
La proprietà currentActiveId serve a ricordare quale link è attualmente attivo, in modo da poter rimuovere la classe da quel link senza dover scorrere l'intera mappa. È una piccola ottimizzazione che si paga con una proprietà di stato in più, ma rende il codice più leggibile e veloce.
L'inizializzazione
L'intero codice è racchiuso in una IIFE (Immediately Invoked Function Expression) per evitare di inquinare lo scope globale, e l'istanza della classe viene creata all'evento DOMContentLoaded per garantire che il DOM sia completamente parsato prima di operare su di esso.
document.addEventListener('DOMContentLoaded', () => {
new ArticleManager({
rootSelector: '.wrapper'
});
}, false);
Non è necessario aspettare l'evento load, che si attiva solo dopo il caricamento di tutte le risorse esterne come immagini e fogli di stile: la nostra logica opera solo sulla struttura del documento, che è disponibile già a DOMContentLoaded.
Considerazioni su prestazioni e memoria
L'approccio basato su IntersectionObserver è significativamente più efficiente di una soluzione basata sull'evento scroll per diversi motivi. Il browser gestisce internamente l'osservazione in modo ottimizzato, raggruppando i calcoli di intersezione e invocando il callback solo quando necessario. Il callback viene eseguito su un thread separato rispetto allo scroll vero e proprio, evitando di rallentare l'animazione di scorrimento. Infine, la quantità di lavoro per cambio di sezione è costante e non dipende dal numero totale di sezioni nell'articolo.
Dal punto di vista della memoria, l'unica struttura dati che cresce con il numero di sezioni è la Map dei link, che occupa spazio proporzionale al numero di intestazioni. Per articoli realistici, anche con decine di sezioni, l'occupazione è trascurabile.
Possibili estensioni
Il codice presentato è funzionale e compatto, ma può essere esteso in diverse direzioni a seconda delle esigenze. Una prima estensione naturale è lo scroll fluido al click sui link del sommario, ottenibile con la proprietà CSS scroll-behavior: smooth sull'elemento html oppure programmaticamente con element.scrollIntoView({ behavior: 'smooth' }). Una seconda estensione è l'aggiornamento dell'URL con l'hash della sezione corrente tramite history.replaceState(), in modo che l'utente possa copiare il link e condividere la posizione esatta dell'articolo. Una terza estensione è l'osservazione anche delle intestazioni di terzo livello per costruire un sommario gerarchico, con annidamento delle voci e attivazione coordinata tra livelli.
Per la gestione del primo caricamento, in cui nessuna sezione è ancora nella banda di attivazione, si può chiamare manualmente setActiveLink('section-1') al termine di registerObserver() per garantire che la prima voce del sommario sia evidenziata fin dall'inizio.
Demo
JavaScript Intersection Observer
Conclusione
L'implementazione di un sommario con voce attiva sincronizzata allo scroll è un esempio concreto di come le API moderne del browser permettano di risolvere problemi di interfaccia un tempo complessi con poche righe di codice efficiente. La IntersectionObserver API, in particolare, è uno strumento che merita di essere conosciuto a fondo perché trova applicazione in molti scenari analoghi: lazy loading di immagini, animazioni attivate dallo scroll, conteggio delle visualizzazioni di un elemento, e in generale ogni situazione in cui sia necessario reagire all'apparizione o scomparsa di un elemento nel viewport. La combinazione con una struttura dati a mappa per il lookup rapido dei link, e con una semplice classe ES6 per organizzare le responsabilità, produce un componente riutilizzabile e manutenibile.