Costruire un'intestazione dinamica con l'osservatore di intersezione

Pubblicato: 2022-03-10
Riepilogo rapido ↬ Hai mai avuto bisogno di creare un'interfaccia utente in cui alcuni componenti della pagina devono rispondere agli elementi mentre vengono fatti scorrere fino a una certa soglia all'interno del viewport, o forse dentro e fuori il viewport stesso? In JavaScript, collegare un listener di eventi per attivare costantemente un callback allo scorrimento può richiedere prestazioni elevate e, se usato in modo non saggio, può creare un'esperienza utente lenta. Ma c'è un modo migliore con Intersection Observer.

L'Intersection Observer API è un'API JavaScript che ci consente di osservare un elemento e rilevare quando passa un punto specifico in un contenitore a scorrimento, spesso (ma non sempre) il viewport, attivando una funzione di callback.

Intersection Observer può essere considerato più performante dell'ascolto di eventi di scorrimento sul thread principale, poiché è asincrono e il callback verrà attivato solo quando l'elemento che stiamo osservando raggiunge la soglia specificata, invece ogni volta che la posizione di scorrimento viene aggiornata. In questo articolo, illustreremo un esempio di come utilizzare Intersection Observer per creare un componente di intestazione fisso che cambia quando si interseca con diverse sezioni della pagina Web.

Utilizzo di base

Per utilizzare Intersection Observer, dobbiamo prima creare un nuovo osservatore, che accetta due parametri: un oggetto con le opzioni dell'osservatore e la funzione di callback che vogliamo eseguire ogni volta che l'elemento che stiamo osservando (noto come target dell'osservatore) si interseca con la radice (il contenitore a scorrimento, che deve essere un predecessore dell'elemento di destinazione).

 const options = { root: document.querySelector('[data-scroll-root]'), rootMargin: '0px', threshold: 1.0 } const callback = (entries, observer) => { entries.forEach((entry) => console.log(entry)) } const observer = new IntersectionObserver(callback, options)

Quando abbiamo creato il nostro osservatore, dobbiamo quindi istruirlo a guardare un elemento target:

 const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)

Tutti i valori delle opzioni possono essere omessi, poiché torneranno ai loro valori predefiniti:

 const options = { rootMargin: '0px', threshold: 1.0 }

Se non viene specificata alcuna radice, verrà classificata come viewport del browser. L'esempio di codice precedente mostra i valori predefiniti sia per rootMargin che per threshold . Questi possono essere difficili da visualizzare, quindi vale la pena spiegarli:

rootMargin

Il valore rootMargin è un po' come aggiungere i margini CSS all'elemento radice e, proprio come i margini, può assumere più valori, compresi i valori negativi. L'elemento target sarà considerato intersecante rispetto ai margini.

La radice di scorrimento con valori di margine della radice positivi e negativi. Il quadrato arancione è posizionato nel punto in cui verrebbe classificato come "intersecante", assumendo un valore di soglia predefinito pari a 1. (Anteprima grande)

Ciò significa che un elemento può essere tecnicamente classificato come "intersecante" anche quando è fuori vista (se la nostra radice di scorrimento è il viewport).

Il quadrato arancione si interseca con la radice, anche se è al di fuori dell'area visibile. (Grande anteprima)

rootMargin su 0px , ma può accettare una stringa composta da più valori, proprio come usare la proprietà margin in CSS.

threshold

La threshold può essere costituita da un singolo valore o da una matrice di valori compresi tra 0 e 1. Rappresenta la proporzione dell'elemento che deve trovarsi all'interno dei limiti della radice per essere considerata intersecante . Utilizzando il valore predefinito di 1, il callback verrà attivato quando il 100% dell'elemento di destinazione è visibile all'interno della radice.

Le soglie rispettivamente di 1, 0 e 0,5 determinano l'attivazione della richiamata quando è visibile il 100%, 0% e 50%. (Grande anteprima)

Non è sempre facile visualizzare quando un elemento verrà classificato come visibile utilizzando queste opzioni. Ho creato un piccolo strumento per aiutare a fare i conti con Intersection Observer.

Altro dopo il salto! Continua a leggere sotto ↓

Creazione dell'intestazione

Ora che abbiamo afferrato i principi di base, iniziamo a costruire la nostra intestazione dinamica. Inizieremo con una pagina web divisa in sezioni. Questa immagine mostra il layout completo della pagina che creeremo:

(Grande anteprima)

Ho incluso una demo alla fine di questo articolo, quindi sentiti libero di passare direttamente ad essa se desideri deselezionare il codice. (C'è anche un repository Github.)

Ogni sezione ha un'altezza minima di 100vh (anche se potrebbero essere più lunghe, a seconda del contenuto). La nostra intestazione è fissata nella parte superiore della pagina e rimane al suo posto mentre l'utente scorre (usando position: fixed ). Le sezioni hanno sfondi di colore diverso e, quando incontrano l'intestazione, i colori dell'intestazione cambiano per completare quelli della sezione. C'è anche un indicatore per mostrare la sezione corrente in cui si trova l'utente, che scorre quando arriva la sezione successiva. Per facilitarci l'accesso diretto al codice pertinente, ho impostato una demo minima con il nostro punto di partenza (prima di iniziare a utilizzare l'API di Intersection Observer), nel caso in cui desideri continuare.

Marcatura

Inizieremo con l'HTML per la nostra intestazione. Questa sarà un'intestazione abbastanza semplice con un link home e una navigazione, niente di particolarmente elegante, ma useremo un paio di attributi di dati: data-header per l'intestazione stessa (quindi possiamo indirizzare l'elemento con JS) e tre link di ancoraggio con l'attributo data-link , che farà scorrere l'utente alla sezione pertinente quando viene cliccato:

 <header data-header> <nav class="header__nav"> <div class="header__left-content"> <a href="#0">Home</a> </div> <ul class="header__list"> <li> <a href="#about-us" data-link>About us</a> </li> <li> <a href="#flavours" data-link>The flavours</a> </li> <li> <a href="#get-in-touch" data-link>Get in touch</a> </li> </ul> </nav> </header>

Successivamente, l'HTML per il resto della nostra pagina, che è divisa in sezioni. Per brevità, ho incluso solo le parti rilevanti per l'articolo, ma il markup completo è incluso nella demo. Ogni sezione include un attributo di dati che specifica il nome del colore di sfondo e un id che corrisponde a uno dei link di ancoraggio nell'intestazione:

 <main> <section data-section="raspberry"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>

Posizioniamo la nostra intestazione con CSS in modo che rimanga fissa nella parte superiore della pagina mentre l'utente scorre:

 header { position: fixed; width: 100%; }

Daremo anche alle nostre sezioni un'altezza minima e centramo il contenuto. (Questo codice non è necessario per il funzionamento di Intersection Observer, è solo per il design.)

 section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }

Avviso iframe

Durante la creazione di questa demo di Codepen, mi sono imbattuto in un problema sconcertante in cui il mio codice di Intersection Observer che avrebbe dovuto funzionare perfettamente non attivava la richiamata nel punto corretto dell'intersezione, ma si attivava invece quando l'elemento target si intersecava con il bordo della vista. Dopo un po' di grattacapo, mi sono reso conto che ciò era dovuto al fatto che in Codepen il contenuto viene caricato all'interno di un iframe, che viene trattato in modo diverso. (Vedi la sezione dei documenti MDN sul ritaglio e il rettangolo di intersezione per i dettagli completi.)

Come soluzione alternativa, nella demo possiamo racchiudere il nostro markup in un altro elemento, che fungerà da contenitore di scorrimento - la radice nelle nostre opzioni IO - piuttosto che il viewport del browser, come potremmo aspettarci:

 <div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>

Se vuoi vedere come utilizzare il viewport come root invece per la stessa demo, questo è incluso nel repository Github.

CSS

Nel nostro CSS definiremo alcune proprietà personalizzate per i colori che stiamo usando. Definiremo anche due proprietà personalizzate aggiuntive per il testo dell'intestazione e i colori di sfondo e imposteremo alcuni valori iniziali. (Aggiorneremo queste due proprietà personalizzate per le diverse sezioni in seguito.)

 :root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }

Useremo queste proprietà personalizzate nella nostra intestazione:

 header { background-color: var(--headerBg); color: var(--headerText); }

Imposteremo anche i colori per le nostre diverse sezioni. Sto usando gli attributi dei dati come selettori, ma potresti usare altrettanto facilmente una classe se preferisci.

 [data-section="raspberry"] { background-color: var(--raspberry); color: var(--vanilla); } [data-section="mint"] { background-color: var(--mint); color: var(--chocolate); } [data-section="vanilla"] { background-color: var(--vanilla); color: var(--chocolate); } [data-section="chocolate"] { background-color: var(--chocolate); color: var(--vanilla); }

Possiamo anche impostare alcuni stili per la nostra intestazione quando ogni sezione è in vista:

 /* Header */ [data-theme="raspberry"] { --headerText: var(--raspberry); --headerBg: var(--vanilla); } [data-theme="mint"] { --headerText: var(--mint); --headerBg: var(--chocolate); } [data-theme="chocolate"] { --headerText: var(--chocolate); --headerBg: var(--vanilla); }

C'è un caso più forte per l'utilizzo degli attributi dei dati qui perché alterneremo l'attributo del data-theme dell'intestazione su ogni intersezione.

Creazione dell'osservatore

Ora che abbiamo l'HTML e il CSS di base per la nostra pagina impostata, possiamo creare un osservatore per osservare ogni nostra sezione che viene visualizzata. Vogliamo attivare una richiamata ogni volta che una sezione entra in contatto con la parte inferiore dell'intestazione mentre scorriamo la pagina. Ciò significa che dobbiamo impostare un margine radice negativo che corrisponda all'altezza dell'intestazione.

 const header = document.querySelector('[data-header]') const sections = [...document.querySelectorAll('[data-section]')] const scrollRoot = document.querySelector('[data-scroller]') const options = { root: scrollRoot, rootMargin: `${header.offsetHeight * -1}px`, threshold: 0 }

Stiamo impostando una soglia di 0 , poiché vogliamo che si attivi se una qualsiasi parte della sezione si interseca con il margine della radice.

Prima di tutto, creeremo un callback per modificare il valore del data-theme dell'intestazione. (Questo è più semplice dell'aggiunta e della rimozione di classi, specialmente quando il nostro elemento di intestazione potrebbe avere altre classi applicate.)

 /* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }

Quindi creeremo l'osservatore per osservare le sezioni che si intersecano:

 /* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })

Ora dovremmo vedere l'aggiornamento dei colori dell'intestazione quando ogni sezione incontra l'intestazione.

Guarda la penna [Happy Face Ice Cream Parlor – Step 2](https://codepen.io/smashingmag/pen/poPgpjZ) di Michelle Barker.

Guarda la gelateria Pen Happy Face – Fase 2 di Michelle Barker.

Tuttavia, potresti notare che i colori non si aggiornano correttamente mentre scorriamo verso il basso. In effetti, l'intestazione si aggiorna ogni volta con i colori della sezione precedente! Scorrendo verso l'alto, invece, funziona perfettamente. Dobbiamo determinare la direzione di scorrimento e modificare il comportamento di conseguenza.

Trovare la direzione di scorrimento

Imposteremo una variabile nel nostro JS per la direzione di scorrimento, con un valore iniziale di 'up' e un altro per l'ultima posizione di scorrimento nota ( prevYPosition ). Quindi, all'interno della callback, se la posizione di scorrimento è maggiore del valore precedente, possiamo impostare il valore della direction come 'down' o 'up' se viceversa.

 let direction = 'up' let prevYPosition = 0 const setScrollDirection = () => { if (scrollRoot.scrollTop > prevYPosition) { direction = 'down' } else { direction = 'up' } prevYPosition = scrollRoot.scrollTop } const onIntersect = (entries, observer) => { entries.forEach((entry) => { setScrollDirection() /* ... */ }) }

Creeremo anche una nuova funzione per aggiornare i colori dell'intestazione, passando nella sezione target come argomento:

 const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }

Finora non dovremmo vedere alcun cambiamento nel comportamento della nostra intestazione. Ma ora che conosciamo la direzione di scorrimento, possiamo passare un target diverso per la nostra funzione updateColors() . Se la direzione di scorrimento è in alto, useremo il target di ingresso. Se è inattivo, utilizzeremo la sezione successiva (se presente).

 const getTargetSection = (target) => { if (direction === 'up') return target if (target.nextElementSibling) { return target.nextElementSibling } else { return target } } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() const target = getTargetSection(entry.target) updateColors(target) }) }

C'è un altro problema, tuttavia: l'intestazione si aggiornerà non solo quando la sezione raggiunge l'intestazione, ma quando l'elemento successivo viene visualizzato nella parte inferiore della finestra. Questo perché il nostro osservatore attiva la richiamata due volte: una volta quando l'elemento sta entrando e di nuovo quando sta uscendo.

Per determinare se l'intestazione deve essere aggiornata, possiamo usare la chiave isIntersecting dall'oggetto entry . Creiamo un'altra funzione per restituire un valore booleano per sapere se i colori dell'intestazione devono essere aggiornati:

 const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }

Aggiorneremo la nostra funzione onIntersect() conseguenza:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() /* Do nothing if no need to update */ if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) }) }

Ora i nostri colori dovrebbero aggiornarsi correttamente. Possiamo impostare una transizione CSS, in modo che l'effetto sia un po' più gradevole:

 header { transition: background-color 200ms, color 200ms; } 

Guarda la penna [Happy Face Ice Cream Parlor – Step 3](https://codepen.io/smashingmag/pen/bGWEaEa) di Michelle Barker.

Guarda la gelateria Pen Happy Face – Step 3 di Michelle Barker.

Aggiunta dell'indicatore dinamico

Successivamente aggiungeremo un marcatore all'intestazione che ne aggiorna la posizione mentre scorriamo alle diverse sezioni. Possiamo usare uno pseudo-elemento per questo, quindi non abbiamo bisogno di aggiungere nulla al nostro HTML. Gli daremo un semplice stile CSS per posizionarlo in alto a sinistra dell'intestazione e dargli un colore di sfondo. Stiamo usando currentColor per questo, poiché assumerà il valore del colore del testo dell'intestazione:

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }

Possiamo usare una proprietà personalizzata per la larghezza, con un valore predefinito di 0. Utilizzeremo anche una proprietà personalizzata per il valore translate x. Imposteremo i valori per questi nella nostra funzione di callback mentre l'utente scorre.

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; width: var(--markerWidth, 0); background-color: currentColor; transform: translate3d(var(--markerLeft, 0), 0, 0); }

Ora possiamo scrivere una funzione che aggiornerà la larghezza e la posizione del marker nel punto di intersezione:

 const updateMarker = (target) => { const id = target.id /* Do nothing if no target ID */ if (!id) return /* Find the corresponding nav link, or use the first one */ let link = headerLinks.find((el) => { return el.getAttribute('href') === `#${id}` }) link = link || headerLinks[0] /* Get the values and set the custom properties */ const distanceFromLeft = link.getBoundingClientRect().left header.style.setProperty('--markerWidth', `${link.clientWidth}px`) header.style.setProperty('--markerLeft', `${distanceFromLeft}px`) }

Possiamo chiamare la funzione mentre aggiorniamo i colori:

 const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }

Dovremo anche impostare una posizione iniziale per l'indicatore, in modo che non appaia dal nulla. Quando il documento sarà caricato, chiameremo la funzione updateMarker() , usando la prima sezione come destinazione:

 document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })

Infine, aggiungiamo una transizione CSS in modo che il marcatore scorra attraverso l'intestazione da un collegamento all'altro. Durante la transizione della proprietà width , possiamo utilizzare will-change per consentire al browser di eseguire ottimizzazioni.

 header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }

Scorrimento fluido

Per un tocco finale, sarebbe bello se, quando un utente fa clic su un collegamento, scorre senza problemi la pagina, invece di saltare alla sezione. In questi giorni possiamo farlo bene nel nostro CSS, non è richiesto JS! Per un'esperienza più accessibile, è una buona idea rispettare le preferenze di movimento dell'utente implementando lo scorrimento fluido solo se l'utente non ha specificato una preferenza per il movimento ridotto nelle impostazioni di sistema:

 @media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }

Demo finale

Mettendo insieme tutti i passaggi precedenti si ottiene la demo completa.

Guarda la penna [Happy Face Ice Cream Parlor – Intersection Observer example](https://codepen.io/smashingmag/pen/XWRXVXQ) di Michelle Barker.

Guarda l'esempio di Pen Happy Face Ice Cream Parlor – Intersection Observer di Michelle Barker.

Supporto del browser

Intersection Observer è ampiamente supportato nei browser moderni. Ove necessario, può essere compilato in polyfill per i browser meno recenti, ma preferisco adottare un approccio di miglioramento progressivo ove possibile. Nel caso della nostra intestazione, non sarebbe molto dannoso per l'esperienza dell'utente fornire una versione semplice e immutabile per i browser che non supportano.

Per rilevare se Intersection Observer è supportato, possiamo utilizzare quanto segue:

 if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { /* Code to execute if IO is supported */ } else { /* Code to execute if not supported */ }

Risorse

Maggiori informazioni su Intersection Observer:

  • Ampia documentazione, con alcuni esempi pratici da MDN
  • Strumento visualizzatore di intersezione Observer
  • Visibilità dell'elemento temporale con l'API Intersection Observer: un altro tutorial di MDN, che esamina come utilizzare l'IO per tenere traccia della visibilità degli annunci
  • Questo articolo di Denys Mishunov copre alcuni altri usi dell'IO, comprese le risorse a caricamento lento. Anche se ora è meno necessario (grazie all'attributo di loading ), c'è ancora molto da imparare qui.