Attivazione di effetti dopo il completamento dello scorrimento programmatico

Quando si scorre in modo programmatico fino a una sezione o un elemento (soprattutto con behavior: 'smooth'), l'attivazione immediata di un'azione successiva, come il lampeggiamento di uno stato di evidenziazione, l'avvio di un'animazione di ingresso o lo spostamento dello stato attivo, comporta l'esecuzione dell'effetto mentre il target è ancora fuori dallo schermo o a metà dello scorrimento.

In precedenza, gli sviluppatori dovevano indovinare la durata dello scorrimento con setTimeout() (che si attiva troppo presto negli scorrimenti lunghi e troppo tardi in quelli brevi) o eseguire manualmente il wrapping di un listener di eventi scrollend in un Promise con un timer di riserva (perché scrollend non si attiva quando il target è già visibile e non si verifica alcuno scorrimento).

I metodi di scorrimento programmatico su Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) e Window (scroll(), scrollTo(), scrollBy()) restituiscono un Promise che si risolve al termine dell'operazione di scorrimento. Se il target si trova già nella posizione di scorrimento richiesta e non è necessario scorrere, l'oggetto Promise restituito viene risolto immediatamente senza bloccarsi.

Implementazione

  1. Contrassegna il gestore come async: utilizza una funzione async per il listener di eventi o il gestore di navigazione che attiva lo scorrimento.
  2. Attendi direttamente il metodo di scorrimento: chiama await element.scrollIntoView({ behavior: 'smooth', ... }) (o await element.scrollTo(...), await window.scrollTo(...) e così via). L'esecuzione viene messa in pausa finché il browser non termina l'animazione di scorrimento e la posizione di scorrimento non si stabilizza.
  3. Applica l'effetto post-scorrimento: attiva/disattiva la classe di evidenziazione, avvia l'animazione o sposta lo stato attivo dopo il completamento dell'espressione await.

Scorrimenti interrotti e gestione del focus

  • Gestione degli scorrimenti rapidi/interrotti: se un nuovo scorrimento inizia sullo stesso contenitore di scorrimento prima che uno scorrimento fluido in corso termini, il browser interrompe lo scorrimento fluido in corso e risolve (non rifiuta) tutte le promesse di scorrimento in attesa su quel contenitore. Se devi sopprimere l'effetto post-scorrimento per gli scorrimenti sostituiti quando un utente fa clic rapidamente, monitora un ID operazione incrementale e restituisci un valore anticipato dopo await se è stato avviato uno scorrimento più recente:
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');
}
  • Spostamento della selezione della tastiera sul target dopo lo scorrimento: quando sposti la selezione della tastiera sull'elemento target (o su un'intestazione al suo interno con tabindex="-1") dopo await targetElement.scrollIntoView(...), passa { preventScroll: true } a focus() in modo che la chiamata di selezione non avvii un secondo scorrimento in competizione:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Vincoli e best practice

  • OBBLIGATORIO: attendi il Promise restituito direttamente da scrollIntoView(), scroll(), scrollTo() o scrollBy() anziché indovinare la durata dello scorrimento con setTimeout().
  • NON inserire scrollend in un new Promise() manuale come implementazione principale: scrollend non si attiva quando il target è già visibile e non viene percorsa alcuna distanza di scorrimento, mentre le promesse del metodo di scorrimento si risolvono automaticamente in questo caso.
  • SÌ: passa { preventScroll: true } quando chiami .focus() dopo aver atteso un metodo di scorrimento, in modo che il browser non salti o scorra una seconda volta.
  • DA FARE: proteggi gli effetti post-scorrimento con un token di richiesta se le interazioni rapide degli utenti possono attivare scorrimenti fluidi sovrapposti sullo stesso contenitore, poiché l'interruzione di uno scorrimento fluido risolve (anziché rifiutare) la promessa precedente.
  • Bug noto di Chrome con lo scroller principale: Chrome presenta un bug per cui la Promise restituita da scrollIntoView() si risolve troppo presto quando è lo scroller principale del documento a scorrere. L'attesa di un scrollIntoView() su un container di scorrimento nidificato funziona come previsto. Non inserire i contenuti in un contenitore di scorrimento, ad esempio div con overflow-y: auto, come soluzione alternativa per questo bug.

Strategie di riserva

Nei browser che non supportano le promise del metodo di scorrimento, scrollIntoView(), scroll(), scrollTo() e scrollBy() restituiscono undefined anziché Promise.

  • Potenziamento progressivo (comportamento predefinito di await): poiché await undefined è un codice JavaScript valido che si risolve immediatamente nel microtask successivo, la scrittura di await element.scrollIntoView({ behavior: 'smooth' }) non genera un errore nei browser non supportati. Invece, si degrada gradualmente eseguendo il codice post-scorrimento non appena inizia lo scorrimento. Per elementi visivi non critici, come un'evidenziazione temporanea, questo è spesso sufficiente.
  • Fallback fedele (scrollend + timeout): se il tuo target Baseline non supporta scroll-promises e l'azione post-scorrimento deve attendere il completamento dello scorrimento nei browser meno recenti, controlla se il valore restituito è un Promise (result instanceof Promise). Se è undefined, attendi l'evento scrollend sul container di scorrimento con un breve timeout di fallback nel caso in cui l'elemento sia già visibile e non si verifichi lo scorrimento:
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);
  });
}