Aufbau eines dynamischen Headers mit Intersection Observer
Veröffentlicht: 2022-03-10Die Intersection Observer API ist eine JavaScript-API, die es uns ermöglicht, ein Element zu beobachten und zu erkennen, wenn es einen bestimmten Punkt in einem scrollenden Container – oft (aber nicht immer) den Viewport – passiert, wodurch eine Callback-Funktion ausgelöst wird.
Intersection Observer kann als leistungsstärker angesehen werden als das Abhören von Scroll-Ereignissen im Haupt-Thread, da es asynchron ist und der Callback nur ausgelöst wird, wenn das von uns beobachtete Element den angegebenen Schwellenwert erreicht, statt jedes Mal, wenn die Scroll-Position aktualisiert wird. In diesem Artikel gehen wir ein Beispiel durch, wie wir Intersection Observer verwenden können, um eine feste Header-Komponente zu erstellen, die sich ändert, wenn sie sich mit verschiedenen Abschnitten der Webseite überschneidet.
Grundlegende Verwendung
Um Intersection Observer zu verwenden, müssen wir zuerst einen neuen Beobachter erstellen, der zwei Parameter benötigt: Ein Objekt mit den Optionen des Beobachters und die Callback-Funktion, die wir ausführen möchten, wenn das von uns beobachtete Element (bekannt als das Beobachterziel) sich schneidet mit der Wurzel (dem scrollenden Container, der ein Vorfahre des Zielelements sein muss).
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)
Wenn wir unseren Beobachter erstellt haben, müssen wir ihn anweisen, ein Zielelement zu beobachten:
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Alle Optionswerte können weggelassen werden, da sie auf ihre Standardwerte zurückfallen:
const options = { rootMargin: '0px', threshold: 1.0 }
Wenn kein Stamm angegeben ist, wird es als Browser-Ansichtsfenster klassifiziert. Das obige Codebeispiel zeigt die Standardwerte für rootMargin
und threshold
. Diese können schwer zu visualisieren sein und sind daher eine Erklärung wert:
rootMargin
Der rootMargin
Wert ist ein bisschen wie das Hinzufügen von CSS-Rändern zum Stammelement – und kann genau wie Ränder mehrere Werte annehmen, einschließlich negativer Werte. Das Zielelement wird relativ zu den Rändern als schneidend betrachtet.
Das bedeutet, dass ein Element technisch als „überschneidend“ eingestuft werden kann, selbst wenn es nicht sichtbar ist (wenn unser Scroll-Stamm der Viewport ist).
rootMargin
standardmäßig 0px
, kann aber eine Zeichenfolge annehmen, die aus mehreren Werten besteht, genau wie die Verwendung der margin
-Eigenschaft in CSS.
threshold
Der threshold
kann aus einem einzelnen Wert oder einem Array von Werten zwischen 0 und 1 bestehen. Er stellt den Anteil des Elements dar, der innerhalb der Wurzelgrenzen liegen muss, damit es als überschneidend betrachtet wird . Bei Verwendung des Standardwerts 1 wird der Callback ausgelöst, wenn 100 % des Zielelements im Stamm sichtbar sind.
Es ist nicht immer einfach, mit diesen Optionen zu visualisieren, wann ein Element als sichtbar eingestuft wird. Ich habe ein kleines Tool gebaut, um den Umgang mit Intersection Observer zu erleichtern.
Kopfzeile erstellen
Nachdem wir nun die Grundprinzipien verstanden haben, beginnen wir mit der Erstellung unseres dynamischen Headers. Wir beginnen mit einer Webseite, die in Abschnitte unterteilt ist. Dieses Bild zeigt das vollständige Layout der Seite, die wir erstellen werden:
Ich habe am Ende dieses Artikels eine Demo eingefügt, also zögern Sie nicht, direkt dorthin zu springen, wenn Sie den Code entpacken möchten. (Es gibt auch ein Github-Repository.)
Jeder Abschnitt hat eine Mindesthöhe von 100vh
(obwohl sie je nach Inhalt länger sein könnten). Unsere Kopfzeile ist oben auf der Seite fixiert und bleibt an Ort und Stelle, wenn der Benutzer scrollt (mit position: fixed
). Die Abschnitte haben unterschiedlich farbige Hintergründe, und wenn sie auf die Kopfzeile treffen, ändern sich die Farben der Kopfzeile, um die des Abschnitts zu ergänzen. Es gibt auch eine Markierung, die den aktuellen Abschnitt anzeigt, in dem sich der Benutzer befindet, der entlang gleitet, wenn der nächste Abschnitt eintrifft. Um es uns zu erleichtern, direkt zum relevanten Code zu gelangen, habe ich eine minimale Demo mit unserem Ausgangspunkt eingerichtet (bevor wir beginnen, die Intersection Observer API zu verwenden), falls Sie mitverfolgen möchten.
Markierung
Wir beginnen mit dem HTML für unseren Header. Dies wird ein ziemlich einfacher Header mit einem Home-Link und einer Navigation sein, nichts Besonderes, aber wir werden ein paar Datenattribute verwenden: data-header
für den Header selbst (damit wir das Element mit JS ansprechen können) , und drei Ankerlinks mit dem Attribut data-link
, die den Nutzer beim Anklicken zum entsprechenden Abschnitt scrollen:
<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>
Als nächstes das HTML für den Rest unserer Seite, die in Abschnitte unterteilt ist. Der Kürze halber habe ich nur die für den Artikel relevanten Teile eingefügt, aber das vollständige Markup ist in der Demo enthalten. Jeder Abschnitt enthält ein Datenattribut, das den Namen der Hintergrundfarbe angibt, und eine id
, die einem der Ankerlinks im Header entspricht:
<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>
Wir positionieren unseren Header mit CSS so, dass er beim Scrollen des Benutzers oben auf der Seite fixiert bleibt:
header { position: fixed; width: 100%; }
Außerdem geben wir unseren Abschnitten eine Mindesthöhe und zentrieren den Inhalt. (Dieser Code ist nicht erforderlich, damit der Intersection Observer funktioniert, er dient nur dem Design.)
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
IFrame-Warnung
Beim Erstellen dieser Codepen-Demo stieß ich auf ein verwirrendes Problem, bei dem mein Intersection Observer-Code, der perfekt hätte funktionieren sollen, den Callback nicht am richtigen Punkt des Schnittpunkts auslöste, sondern stattdessen auslöste, wenn das Zielelement die Kante des Ansichtsfensters schnitt. Nach einigem Kopfkratzen wurde mir klar, dass dies daran lag, dass in Codepen der Inhalt in einen Iframe geladen wird, der anders behandelt wird. (Ausführliche Informationen finden Sie im Abschnitt der MDN-Dokumentation zum Clipping und zum Schnittrechteck.)
Als Problemumgehung können wir in der Demo unser Markup in ein anderes Element einpacken, das als Scrolling-Container fungiert – der Stamm in unseren IO-Optionen – und nicht wie erwartet als Browser-Ansichtsfenster:
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Wenn Sie sehen möchten, wie Sie stattdessen den Viewport als Root für dieselbe Demo verwenden, ist dies im Github-Repository enthalten.
CSS
In unserem CSS definieren wir einige benutzerdefinierte Eigenschaften für die von uns verwendeten Farben. Wir werden auch zwei zusätzliche benutzerdefinierte Eigenschaften für den Kopfzeilentext und die Hintergrundfarben definieren und einige Anfangswerte festlegen. (Wir werden diese beiden benutzerdefinierten Eigenschaften später für die verschiedenen Abschnitte aktualisieren.)
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
Wir verwenden diese benutzerdefinierten Eigenschaften in unserem Header:
header { background-color: var(--headerBg); color: var(--headerText); }
Wir legen auch die Farben für unsere verschiedenen Abschnitte fest. Ich verwende die Datenattribute als Selektoren, aber Sie könnten genauso gut eine Klasse verwenden, wenn Sie dies vorziehen.
[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); }
Wir können auch einige Stile für unsere Kopfzeile festlegen, wenn jeder Abschnitt angezeigt wird:
/* 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); }
Es gibt hier ein stärkeres Argument für die Verwendung von Datenattributen, da wir das Attribut data-theme
des Headers bei jeder Kreuzung umschalten werden.
Den Beobachter erschaffen
Nachdem wir das grundlegende HTML und CSS für unsere Seite eingerichtet haben, können wir einen Beobachter erstellen, der darauf achtet, ob jeder unserer Abschnitte angezeigt wird. Wir möchten einen Rückruf auslösen, wenn ein Abschnitt mit dem unteren Rand der Kopfzeile in Kontakt kommt, während wir die Seite nach unten scrollen. Das bedeutet, dass wir einen negativen Wurzelrand festlegen müssen, der der Höhe des Headers entspricht.
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 }
Wir setzen einen Schwellenwert von 0 , da wir möchten, dass es ausgelöst wird, wenn ein Teil des Abschnitts den Wurzelrand schneidet.
Zunächst erstellen wir einen Rückruf, um den data-theme
des Headers zu ändern. (Dies ist einfacher als das Hinzufügen und Entfernen von Klassen, insbesondere wenn auf unser Header-Element möglicherweise andere Klassen angewendet werden.)
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Dann erstellen wir den Beobachter, der nach den sich schneidenden Abschnitten Ausschau hält:
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Jetzt sollten wir sehen, dass unsere Header-Farben aktualisiert werden, wenn jeder Abschnitt auf den Header trifft.
Möglicherweise stellen Sie jedoch fest, dass die Farben nicht korrekt aktualisiert werden, wenn wir nach unten scrollen. Tatsächlich wird die Kopfzeile jedes Mal mit den Farben des vorherigen Abschnitts aktualisiert! Scrollen nach oben funktioniert hingegen einwandfrei. Wir müssen die Scrollrichtung bestimmen und das Verhalten entsprechend ändern.
Finden der Scrollrichtung
Wir setzen in unserem JS eine Variable für die Bildlaufrichtung mit einem Anfangswert von 'up'
und eine weitere für die letzte bekannte Bildlaufposition ( prevYPosition
). Wenn die Bildlaufposition größer als der vorherige Wert ist, können wir dann innerhalb des Rückrufs den direction
auf 'down'
oder 'up'
setzen, wenn umgekehrt.
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() /* ... */ }) }
Wir werden auch eine neue Funktion erstellen, um die Header-Farben zu aktualisieren, indem wir den Zielabschnitt als Argument übergeben:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Bisher sollten wir keine Änderung am Verhalten unseres Headers sehen. Aber jetzt, da wir die Bildlaufrichtung kennen, können wir ein anderes Ziel für unsere Funktion updateColors()
. Wenn die Bildlaufrichtung nach oben ist, verwenden wir das Einstiegsziel. Wenn es ausgefallen ist, verwenden wir den nächsten Abschnitt (falls vorhanden).
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) }) }
Es gibt jedoch noch ein weiteres Problem: Die Kopfzeile wird nicht nur aktualisiert, wenn der Abschnitt auf die Kopfzeile trifft, sondern auch, wenn das nächste Element am unteren Rand des Ansichtsfensters sichtbar wird. Dies liegt daran, dass unser Beobachter den Rückruf zweimal abfeuert: einmal, wenn das Element eintritt, und erneut, wenn es es verlässt.
Um zu bestimmen, ob der Header aktualisiert werden soll, können wir den Schlüssel isIntersecting
aus dem entry
verwenden. Lassen Sie uns eine weitere Funktion erstellen, um einen booleschen Wert zurückzugeben, ob die Header-Farben aktualisiert werden sollen:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Wir aktualisieren unsere Funktion onIntersect()
entsprechend:
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) }) }
Jetzt sollten unsere Farben korrekt aktualisiert werden. Wir können einen CSS-Übergang setzen, damit der Effekt etwas schöner wird:
header { transition: background-color 200ms, color 200ms; }
Hinzufügen des dynamischen Markers
Als Nächstes fügen wir der Kopfzeile eine Markierung hinzu, die ihre Position aktualisiert, wenn wir zu den verschiedenen Abschnitten scrollen. Wir können dafür ein Pseudo-Element verwenden, sodass wir unserem HTML nichts hinzufügen müssen. Wir geben ihm ein einfaches CSS-Styling, um es oben links in der Kopfzeile zu positionieren, und geben ihm eine Hintergrundfarbe. Wir verwenden dafür currentColor
, da es den Wert der Header-Textfarbe annimmt:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Wir können eine benutzerdefinierte Eigenschaft für die Breite mit einem Standardwert von 0 verwenden. Wir verwenden auch eine benutzerdefinierte Eigenschaft für den x-Wert der Übersetzung. Wir werden die Werte dafür in unserer Callback-Funktion festlegen, während der Benutzer scrollt.
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); }
Jetzt können wir eine Funktion schreiben, die die Breite und Position der Markierung am Schnittpunkt aktualisiert:
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`) }
Wir können die Funktion aufrufen, während wir gleichzeitig die Farben aktualisieren:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
Wir müssen auch eine Anfangsposition für den Marker festlegen, damit er nicht einfach aus dem Nichts erscheint. Wenn das Dokument geladen ist, rufen wir die Funktion updateMarker()
auf, wobei wir den ersten Abschnitt als Ziel verwenden:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Zum Schluss fügen wir einen CSS-Übergang hinzu, sodass die Markierung von einem Link zum nächsten über die Kopfzeile gleitet. Während wir die width
-Eigenschaft umstellen, können wir will-change
verwenden, damit der Browser Optimierungen durchführen kann.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Flüssiges Scrollen
Für den letzten Schliff wäre es schön, wenn ein Benutzer, wenn er auf einen Link klickt, reibungslos auf der Seite nach unten gescrollt wird, anstatt zum Abschnitt zu springen. Heutzutage können wir es direkt in unserem CSS machen, kein JS erforderlich! Für eine zugänglichere Erfahrung ist es eine gute Idee, die Bewegungspräferenzen des Benutzers zu respektieren, indem Sie glattes Scrollen nur implementieren, wenn er in seinen Systemeinstellungen keine Präferenz für reduzierte Bewegung angegeben hat:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
Letzte Demo
Wenn Sie alle oben genannten Schritte zusammenführen, erhalten Sie die vollständige Demo.
Browser-Unterstützung
Intersection Observer wird in modernen Browsern weitgehend unterstützt. Bei Bedarf kann es für ältere Browser polyfilled werden – aber ich bevorzuge, wo immer möglich, einen progressiven Erweiterungsansatz. Im Fall unseres Headers wäre es für die Benutzererfahrung nicht sehr nachteilig, eine einfache, unveränderliche Version für nicht unterstützende Browser bereitzustellen.
Um festzustellen, ob Intersection Observer unterstützt wird, können wir Folgendes verwenden:
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 */ }
Ressourcen
Lesen Sie mehr über Intersection Observer:
- Umfangreiche Dokumentation, mit einigen praktischen Beispielen von MDN
- Intersection Observer-Visualisierungstool
- Sichtbarkeit von Timing-Elementen mit der Intersection Observer API – ein weiteres Tutorial von MDN, das untersucht, wie IO verwendet werden kann, um die Sichtbarkeit von Anzeigen zu verfolgen
- Dieser Artikel von Denys Mishunov behandelt einige andere Verwendungen für IO, einschließlich Lazy-Loading-Assets. Obwohl das jetzt weniger notwendig ist (dank des
loading
), gibt es hier noch viel zu lernen.