Lorsque vous faites défiler l'écran de manière programmatique vers une section ou un élément (en particulier avec behavior: 'smooth'), le déclenchement immédiat d'une action de suivi (par exemple, faire clignoter un état de mise en surbrillance, démarrer une animation d'entrée ou déplacer la sélection) entraîne l'exécution de l'effet alors que la cible est toujours hors de l'écran ou en cours de défilement.
Auparavant, les développeurs devaient deviner la durée de défilement avec setTimeout() (qui se déclenche trop tôt sur les défilements longs et trop tard sur les défilements courts) ou encapsuler manuellement un écouteur d'événements scrollend dans un Promise avec un minuteur de remplacement (car scrollend ne se déclenche pas lorsque la cible est déjà visible et qu'aucun défilement ne se produit).
Les méthodes de défilement programmatique sur Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) et Window (scroll(), scrollTo(), scrollBy()) renvoient un Promise qui se résout lorsque l'opération de défilement est terminée. Si la cible se trouve déjà à la position de défilement demandée et qu'aucun défilement n'est nécessaire, le Promise renvoyé est résolu immédiatement sans blocage.
Implémentation
- Marquez le gestionnaire comme
async: utilisez une fonctionasyncpour l'écouteur d'événements ou le gestionnaire de navigation qui déclenche le défilement. - Attendre directement la méthode de défilement : appelez
await element.scrollIntoView({ behavior: 'smooth', ... })(ouawait element.scrollTo(...),await window.scrollTo(...), etc.). L'exécution est suspendue jusqu'à ce que le navigateur termine l'animation de défilement et que la position de défilement se stabilise. - Appliquer l'effet post-défilement : activez/désactivez la classe de mise en surbrillance, démarrez l'animation ou déplacez le curseur une fois l'expression
awaitterminée.
Défilements interrompus et gestion du focus
- Gestion des défilements rapides/interrompus : si un nouveau défilement commence sur le même conteneur de défilement avant la fin d'un défilement fluide en cours, le navigateur annule le défilement fluide en cours et résout (ne rejette pas) toutes les promesses de défilement en attente sur ce conteneur. Si vous devez supprimer l'effet post-défilement pour les défilements remplacés lorsqu'un utilisateur clique rapidement, suivez un ID d'opération incrémentiel et revenez rapidement après
awaitsi un nouveau défilement a été lancé :
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');
}
- Placer le focus sur la cible après le défilement : lorsque vous déplacez le focus clavier vers l'élément cible (ou un titre qu'il contient avec
tabindex="-1") aprèsawait targetElement.scrollIntoView(...), transmettez{ preventScroll: true }àfocus()pour que l'appel de focus ne lance pas un deuxième défilement concurrent :
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });
Contraintes et bonnes pratiques
- OBLIGATOIRE : attendez le
Promiserenvoyé directement parscrollIntoView(),scroll(),scrollTo()ouscrollBy()au lieu de deviner les durées de défilement avecsetTimeout(). - NE PAS encapsuler
scrollenddans unnew Promise()manuel comme implémentation principale :scrollendne se déclenche pas lorsque la cible est déjà visible et qu'aucune distance de défilement n'est parcourue, tandis que les promesses de la méthode de défilement se résolvent automatiquement dans ce cas. - À FAIRE : Transmettez
{ preventScroll: true }lorsque vous appelez.focus()après avoir attendu une méthode de défilement, afin que le navigateur ne saute pas ou ne défile pas une deuxième fois. - À FAIRE : Protégez les effets post-défilement avec un jeton de requête si des interactions utilisateur rapides peuvent déclencher des défilements fluides qui se chevauchent sur le même conteneur, car l'interruption d'un défilement fluide résout (plutôt que de rejeter) la promesse précédente.
- Bug Chrome connu avec le défilement racine : Chrome présente un bug où la
Promiserenvoyée parscrollIntoView()se résout trop tôt lorsque c'est le défilement racine du document qui défile. L'attente d'unscrollIntoView()sur un conteneur de défilement imbriqué fonctionne comme prévu. Pour contourner ce bug, n'encapsulez pas votre contenu dans un conteneur de défilement, tel quedivavecoverflow-y: auto.
Stratégies de remplacement
Dans les navigateurs qui ne sont pas compatibles avec les promesses de méthode de défilement, scrollIntoView(), scroll(), scrollTo() et scrollBy() renvoient undefined au lieu d'un Promise.
- Amélioration progressive (comportement par défaut de
await) : étant donné queawait undefinedest un code JavaScript valide qui se résout immédiatement lors de la microtâche suivante, l'écriture deawait element.scrollIntoView({ behavior: 'smooth' })ne génère pas d'erreur dans les navigateurs non compatibles. Au lieu de cela, il se dégrade progressivement en exécutant le code post-défilement dès que le défilement commence. Pour les éléments visuels non critiques, comme une mise en surbrillance temporaire, cela est souvent suffisant. - Solution de secours fidèle (
scrollend+ délai avant expiration) : si votre cible de référence n'est pas compatible avecscroll-promiseset que l'action après le défilement doit attendre la fin du défilement dans les anciens navigateurs, vérifiez si la valeur renvoyée est unPromise(result instanceof Promise). Lorsque la valeur estundefined, attendez l'événementscrollendsur le conteneur de défilement avec un court délai avant expiration de secours au cas où l'élément est déjà visible et qu'aucun défilement ne se produit :
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);
});
}