Construyendo un encabezado dinámico con Intersection Observer
Publicado: 2022-03-10La API Intersection Observer es una API de JavaScript que nos permite observar un elemento y detectar cuándo pasa un punto específico en un contenedor de desplazamiento, a menudo (pero no siempre) la ventana gráfica, lo que activa una función de devolución de llamada.
Intersection Observer puede considerarse más eficaz que escuchar eventos de desplazamiento en el subproceso principal, ya que es asíncrono y la devolución de llamada solo se activará cuando el elemento que estamos observando alcance el umbral especificado, en lugar de eso, cada vez que se actualice la posición de desplazamiento. En este artículo, veremos un ejemplo de cómo podemos usar Intersection Observer para crear un componente de encabezado fijo que cambia cuando se cruza con diferentes secciones de la página web.
Uso básico
Para usar Intersection Observer, primero debemos crear un nuevo observador, que toma dos parámetros: un objeto con las opciones del observador y la función de devolución de llamada que queremos ejecutar cada vez que el elemento que estamos observando (conocido como el objetivo del observador) se cruza con la raíz (el contenedor de desplazamiento, que debe ser un ancestro del elemento de destino).
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)
Cuando hayamos creado nuestro observador, debemos indicarle que mire un elemento de destino:
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Se puede omitir cualquiera de los valores de las opciones, ya que volverán a sus valores predeterminados:
const options = { rootMargin: '0px', threshold: 1.0 }
Si no se especifica una raíz, se clasificará como la ventana gráfica del navegador. El ejemplo de código anterior muestra los valores predeterminados para rootMargin
y threshold
. Estos pueden ser difíciles de visualizar, por lo que vale la pena explicarlos:
rootMargin
El valor de rootMargin
es un poco como agregar márgenes CSS al elemento raíz y, al igual que los márgenes, puede tomar múltiples valores, incluidos valores negativos. Se considerará que el elemento de destino se cruza en relación con los márgenes.
Eso significa que un elemento puede clasificarse técnicamente como "intersectado" incluso cuando está fuera de la vista (si nuestra raíz de desplazamiento es la ventana gráfica).
rootMargin
como valor predeterminado 0px
, pero puede tomar una cadena que consta de varios valores, al igual que usar la propiedad de margin
en CSS.
threshold
El threshold
puede consistir en un solo valor o una matriz de valores entre 0 y 1. Representa la proporción del elemento que debe estar dentro de los límites de la raíz para que se considere que intersecta . Usando el valor predeterminado de 1, la devolución de llamada se activará cuando el 100 % del elemento de destino sea visible dentro de la raíz.
No siempre es fácil visualizar cuándo un elemento se clasificará como visible usando estas opciones. He creado una pequeña herramienta para ayudar a familiarizarse con Intersection Observer.
Creando el encabezado
Ahora que hemos comprendido los principios básicos, comencemos a construir nuestro encabezado dinámico. Comenzaremos con una página web dividida en secciones. Esta imagen muestra el diseño completo de la página que construiremos:
He incluido una demostración al final de este artículo, así que no dudes en ir directamente a ella si quieres descifrar el código. (También hay un repositorio de Github).
Cada sección tiene una altura mínima de 100vh
(aunque podrían ser más largas, dependiendo del contenido). Nuestro encabezado está fijo en la parte superior de la página y permanece en su lugar mientras el usuario se desplaza (usando position: fixed
). Las secciones tienen fondos de diferentes colores y, cuando se encuentran con el encabezado, los colores del encabezado cambian para complementar los de la sección. También hay un marcador para mostrar la sección actual en la que se encuentra el usuario, que se desliza cuando llega la siguiente sección. Para que nos sea más fácil llegar directamente al código relevante, he configurado una demostración mínima con nuestro punto de partida (antes de que comencemos a usar la API de Intersection Observer), en caso de que quiera seguirnos.
Margen
Comenzaremos con el HTML de nuestro encabezado. Este va a ser un encabezado bastante simple con un enlace de inicio y navegación, nada especialmente elegante, pero vamos a usar un par de atributos de datos: data-header
de datos para el encabezado en sí (para que podamos apuntar al elemento con JS) , y tres enlaces ancla con el atributo data-link
, que desplazará al usuario a la sección relevante cuando haga clic:
<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>
A continuación, el HTML para el resto de nuestra página, que se divide en secciones. Para abreviar, solo he incluido las partes relevantes para el artículo, pero el marcado completo está incluido en la demostración. Cada sección incluye un atributo de datos que especifica el nombre del color de fondo y una id
que corresponde a uno de los enlaces de anclaje en el encabezado:
<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>
Posicionaremos nuestro encabezado con CSS para que permanezca fijo en la parte superior de la página mientras el usuario se desplaza:
header { position: fixed; width: 100%; }
También daremos a nuestras secciones una altura mínima y centraremos el contenido. (Este código no es necesario para que Intersection Observer funcione, es solo para el diseño).
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
Advertencia de iframe
Mientras construía esta demostración de Codepen, me encontré con un problema desconcertante en el que mi código Intersection Observer que debería haber funcionado perfectamente no activaba la devolución de llamada en el punto correcto de la intersección, sino que activaba cuando el elemento de destino se cruzaba con el borde de la ventana gráfica. Después de rascarme un poco la cabeza, me di cuenta de que esto se debía a que en Codepen el contenido se carga dentro de un iframe, que se trata de manera diferente. (Consulte la sección de los documentos de MDN sobre Recorte y el rectángulo de intersección para obtener detalles completos).
Como solución alternativa, en la demostración podemos envolver nuestro marcado en otro elemento, que actuará como el contenedor de desplazamiento, la raíz en nuestras opciones de IO, en lugar de la ventana gráfica del navegador, como podríamos esperar:
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Si desea ver cómo usar la ventana gráfica como la raíz para la misma demostración, esto se incluye en el repositorio de Github.
CSS
En nuestro CSS definiremos algunas propiedades personalizadas para los colores que estamos usando. También definiremos dos propiedades personalizadas adicionales para el texto del encabezado y los colores de fondo, y estableceremos algunos valores iniciales. (Vamos a actualizar estas dos propiedades personalizadas para las diferentes secciones más adelante).
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
Usaremos estas propiedades personalizadas en nuestro encabezado:
header { background-color: var(--headerBg); color: var(--headerText); }
También estableceremos los colores para nuestras diferentes secciones. Estoy usando los atributos de datos como selectores, pero podrías usar una clase con la misma facilidad si lo prefieres.
[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); }
También podemos establecer algunos estilos para nuestro encabezado cuando cada sección está a la 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); }
Hay un caso más sólido para usar atributos de datos aquí porque vamos a alternar el atributo de data-theme
del encabezado en cada intersección.
Creando el observador
Ahora que tenemos el HTML y el CSS básicos para configurar nuestra página, podemos crear un observador para ver cada una de nuestras secciones que aparecen. Queremos activar una devolución de llamada cada vez que una sección entre en contacto con la parte inferior del encabezado mientras nos desplazamos hacia abajo en la página. Esto significa que debemos establecer un margen raíz negativo que corresponda a la altura del encabezado.
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 }
Estamos estableciendo un umbral de 0 , ya que queremos que se active si alguna parte de la sección se cruza con el margen de la raíz.
En primer lugar, crearemos una devolución de llamada para cambiar el valor del data-theme
del encabezado. (Esto es más sencillo que agregar y eliminar clases, especialmente cuando nuestro elemento de encabezado puede tener otras clases aplicadas).
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Luego, crearemos el observador para observar las secciones que se cruzan:
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Ahora deberíamos ver que los colores de nuestro encabezado se actualizan cuando cada sección se encuentra con el encabezado.
Sin embargo, es posible que observe que los colores no se actualizan correctamente a medida que nos desplazamos hacia abajo. De hecho, ¡el encabezado se actualiza cada vez con los colores de la sección anterior! Desplazamiento hacia arriba, por otro lado, funciona perfectamente. Necesitamos determinar la dirección de desplazamiento y cambiar el comportamiento en consecuencia.
Encontrar la dirección de desplazamiento
Estableceremos una variable en nuestro JS para la dirección de desplazamiento, con un valor inicial de 'up'
, y otra para la última posición de desplazamiento conocida ( prevYPosition
). Luego, dentro de la devolución de llamada, si la posición de desplazamiento es mayor que el valor anterior, podemos establecer el valor de direction
como 'down'
o 'up'
si es 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() /* ... */ }) }
También crearemos una nueva función para actualizar los colores del encabezado, pasando la sección de destino como argumento:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Hasta ahora no deberíamos ver ningún cambio en el comportamiento de nuestro encabezado. Pero ahora que conocemos la dirección de desplazamiento, podemos pasar un objetivo diferente para nuestra función updateColors()
. Si la dirección de desplazamiento es hacia arriba, usaremos el objetivo de entrada. Si está inactivo, usaremos la siguiente sección (si la hay).
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) }) }
Sin embargo, hay un problema más: el encabezado se actualizará no solo cuando la sección toque el encabezado, sino también cuando el siguiente elemento aparezca en la parte inferior de la ventana gráfica. Esto se debe a que nuestro observador activa la devolución de llamada dos veces: una cuando el elemento entra y otra vez cuando sale.
Para determinar si el encabezado debe actualizarse, podemos usar la clave isIntersecting
del objeto de entry
. Vamos a crear otra función para devolver un valor booleano sobre si los colores del encabezado deben actualizarse:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Actualizaremos nuestra función onIntersect()
consecuencia:
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) }) }
Ahora nuestros colores deberían actualizarse correctamente. Podemos establecer una transición CSS, para que el efecto sea un poco más agradable:
header { transition: background-color 200ms, color 200ms; }
Agregar el marcador dinámico
A continuación, agregaremos un marcador al encabezado que actualiza su posición a medida que nos desplazamos a las diferentes secciones. Podemos usar un pseudoelemento para esto, por lo que no necesitamos agregar nada a nuestro HTML. Le daremos un estilo CSS simple para colocarlo en la parte superior izquierda del encabezado y le daremos un color de fondo. Estamos usando currentColor
para esto, ya que tomará el valor del color del texto del encabezado:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Podemos usar una propiedad personalizada para el ancho, con un valor predeterminado de 0. También usaremos una propiedad personalizada para el valor de traducción x. Vamos a establecer los valores para estos en nuestra función de devolución de llamada a medida que el usuario se desplaza.
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); }
Ahora podemos escribir una función que actualice el ancho y la posición del marcador en el punto de intersección:
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`) }
Podemos llamar a la función al mismo tiempo que actualizamos los colores:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
También necesitaremos establecer una posición inicial para el marcador, para que no aparezca de la nada. Cuando se cargue el documento, llamaremos a la función updateMarker()
, usando la primera sección como destino:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Finalmente, agreguemos una transición CSS para que el marcador se deslice por el encabezado de un enlace al siguiente. Mientras hacemos la transición de la propiedad de width
, podemos usar will-change
para permitir que el navegador realice optimizaciones.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Desplazamiento suave
Para un toque final, sería bueno si, cuando un usuario hace clic en un enlace, se desplaza suavemente hacia abajo en la página, en lugar de saltar a la sección. En estos días podemos hacerlo bien en nuestro CSS, ¡no se requiere JS! Para una experiencia más accesible, es una buena idea respetar las preferencias de movimiento del usuario implementando solo un desplazamiento suave si no ha especificado una preferencia de movimiento reducido en la configuración de su sistema:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
demostración final
La combinación de todos los pasos anteriores da como resultado la demostración completa.
Compatibilidad con navegador
Intersection Observer es ampliamente compatible con los navegadores modernos. Cuando sea necesario, se puede policompletar para navegadores más antiguos, pero prefiero adoptar un enfoque de mejora progresiva cuando sea posible. En el caso de nuestro encabezado, no sería muy perjudicial para la experiencia del usuario proporcionar una versión simple e inalterable para los navegadores que no son compatibles.
Para detectar si Intersection Observer es compatible, podemos usar lo siguiente:
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 */ }
Recursos
Lea más sobre Intersection Observer:
- Amplia documentación, con algunos ejemplos prácticos de MDN
- Herramienta de visualización Intersection Observer
- Visibilidad del elemento de tiempo con la API Intersection Observer: otro tutorial de MDN que analiza cómo se puede usar IO para rastrear la visibilidad de los anuncios
- Este artículo de Denys Mishunov cubre algunos otros usos de IO, incluidos los activos de carga diferida. Aunque ahora es menos necesario (gracias al atributo de
loading
), todavía hay mucho que aprender aquí.