Cuando se desplaza de manera programática a una sección o un elemento (en especial con behavior: 'smooth'), activar una acción de seguimiento de inmediato, como mostrar un estado destacado, iniciar una animación de entrada o mover el enfoque, hace que el efecto se ejecute mientras el objetivo aún está fuera de la pantalla o en medio del desplazamiento.
Anteriormente, los desarrolladores tenían que adivinar la duración del desplazamiento con setTimeout() (que se activa demasiado pronto en los desplazamientos largos y demasiado tarde en los cortos) o unir manualmente un objeto de escucha de eventos scrollend en un Promise con un temporizador de resguardo (porque scrollend no se activa cuando el objetivo ya está a la vista y no se produce ningún desplazamiento).
Los métodos de desplazamiento programático en Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) y Window (scroll(), scrollTo(), scrollBy()) devuelven un Promise que se resuelve cuando se completa la operación de desplazamiento. Si el destino ya está en la posición de desplazamiento solicitada y no se necesita desplazamiento, el Promise que se devuelve se resuelve de inmediato sin detenerse.
Implementación
- Marca el controlador como
async: Usa una funciónasyncpara el objeto de escucha de eventos o el controlador de navegación que activa el desplazamiento. - Espera directamente el método de desplazamiento: Llama a
await element.scrollIntoView({ behavior: 'smooth', ... })(oawait element.scrollTo(...),await window.scrollTo(...), etcétera). La ejecución se detiene hasta que el navegador finaliza la animación de desplazamiento y se establece la posición de desplazamiento. - Aplica el efecto posterior al desplazamiento: Activa o desactiva la clase de destacado, inicia la animación o mueve el enfoque después de que se complete la expresión
await.
Desplazamientos interrumpidos y administración de focos
- Control de desplazamientos rápidos o interrumpidos: Si se inicia un nuevo desplazamiento en el mismo contenedor de desplazamiento antes de que finalice un desplazamiento fluido en curso, el navegador anula el desplazamiento fluido en curso y resuelve (no rechaza) todas las promesas de desplazamiento pendientes en ese contenedor. Si necesitas suprimir el efecto posterior al desplazamiento para los desplazamientos reemplazados cuando un usuario hace clic rápidamente, haz un seguimiento de un ID de operación incremental y devuelve un valor anticipado después de
awaitsi se inició un desplazamiento más reciente:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');
async function scrollToAndHighlight(targetElement) {
const scrollId = ++currentScrollId;
sections.forEach((sec) => sec.classList.remove('highlight'));
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Abort follow-up effect if another scroll was triggered while awaiting
if (scrollId !== currentScrollId) return;
targetElement.classList.add('highlight');
}
- Enfocar el objetivo después de desplazarse: Cuando muevas el enfoque del teclado al elemento objetivo (o a un encabezado dentro de él con
tabindex="-1") después deawait targetElement.scrollIntoView(...), pasa{ preventScroll: true }afocus()para que la llamada de enfoque no inicie un segundo desplazamiento que compita con el primero:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });
Restricciones y prácticas recomendadas
- OBLIGATORIO: Espera el
Promiseque devuelven directamentescrollIntoView(),scroll(),scrollTo()oscrollBy()en lugar de adivinar las duraciones de desplazamiento consetTimeout(). - NO unas
scrollenden unnew Promise()manual como tu implementación principal, ya quescrollendno se activa cuando el objetivo ya está a la vista y no se recorre ninguna distancia de desplazamiento, mientras que las promesas del método de desplazamiento se resuelven automáticamente en ese caso. - SÍ: Pasa
{ preventScroll: true }cuando llames a.focus()después de esperar un método de desplazamiento para que el navegador no salte ni se desplace una segunda vez. - QUÉ HACER: Protege los efectos posteriores al desplazamiento con un token de solicitud si las interacciones rápidas del usuario pueden activar desplazamientos fluidos superpuestos en el mismo contenedor, ya que interrumpir un desplazamiento fluido resuelve (en lugar de rechazar) la promesa anterior.
- Error conocido de Chrome con el desplazador raíz: Chrome tiene un error en el que la
Promiseque devuelvescrollIntoView()se resuelve demasiado pronto cuando se desplaza el desplazador raíz del documento. Esperar unscrollIntoView()en un contenedor de desplazamiento anidado funciona según lo esperado. Como solución alternativa para este error, no unas tu contenido en un contenedor de desplazamiento, comodivconoverflow-y: auto.
Estrategias de resguardo
En los navegadores que no admiten promesas de métodos de desplazamiento, scrollIntoView(), scroll(), scrollTo() y scrollBy() devuelven undefined en lugar de un Promise.
- Mejora progresiva (comportamiento predeterminado de
await): Debido a queawait undefinedes un código JavaScript válido que se resuelve de inmediato en la siguiente microtarea, escribirawait element.scrollIntoView({ behavior: 'smooth' })no arrojará un error en los navegadores no compatibles. En cambio, se degrada de forma elegante ejecutando el código posterior al desplazamiento en cuanto este comienza. Para los adornos visuales no críticos, como un destacado temporal, esto suele ser suficiente. - Resguardo fiel (
scrollend+ tiempo de espera): Si tu objetivo de Baseline no admitescroll-promisesy la acción posterior al desplazamiento debe esperar hasta que finalice el desplazamiento en navegadores más antiguos, verifica si el valor que se muestra es unPromise(result instanceof Promise). Cuando seaundefined, espera el eventoscrollenden el contenedor de desplazamiento con un tiempo de espera de resguardo breve en caso de que el elemento ya esté a la vista y no se produzca ningún desplazamiento:
function scrollIntoViewAsync(element, options, scroller = window) {
const result = element.scrollIntoView(options);
// Feature detect: in supporting browsers, scrollIntoView() returns a Promise
if (result instanceof Promise) {
return result;
}
// Fallback for browsers where scrollIntoView() returns undefined
return new Promise((resolve) => {
let timeoutId;
const finish = () => {
clearTimeout(timeoutId);
scroller.removeEventListener('scrollend', finish);
scroller.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
// Restart the idle timer on every scroll event. If scrollend never fires
// (browsers without scrollend support), this resolves once scrolling goes idle.
clearTimeout(timeoutId);
timeoutId = setTimeout(finish, 100);
};
scroller.addEventListener('scrollend', finish, { once: true });
scroller.addEventListener('scroll', onScroll, { passive: true });
// If no scroll event fires within the idle window (e.g., already in view), resolve.
// 100ms is an example value; it only needs to exceed the gap between scroll events.
timeoutId = setTimeout(finish, 100);
});
}