Cómo activar efectos después de que se completa el desplazamiento programático

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

  1. Marca el controlador como async: Usa una función async para el objeto de escucha de eventos o el controlador de navegación que activa el desplazamiento.
  2. Espera directamente el método de desplazamiento: Llama a await element.scrollIntoView({ behavior: 'smooth', ... }) (o await 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.
  3. 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 await si 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 de await targetElement.scrollIntoView(...), pasa { preventScroll: true } a focus() 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 Promise que devuelven directamente scrollIntoView(), scroll(), scrollTo() o scrollBy() en lugar de adivinar las duraciones de desplazamiento con setTimeout().
  • NO unas scrollend en un new Promise() manual como tu implementación principal, ya que scrollend no 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 Promise que devuelve scrollIntoView() se resuelve demasiado pronto cuando se desplaza el desplazador raíz del documento. Esperar un scrollIntoView() 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, como div con overflow-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 que await undefined es un código JavaScript válido que se resuelve de inmediato en la siguiente microtarea, escribir await 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 admite scroll-promises y 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 un Promise (result instanceof Promise). Cuando sea undefined, espera el evento scrollend en 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);
  });
}