Commentaires en temps réel sur le défilement scroll-snap

Présentation

Les utilisateurs s'attendent à un retour visuel immédiat lorsqu'ils interagissent avec des éléments d'interface utilisateur tels que des carrousels ou des galeries. Le défilement scroll-snap traditionnel ne fournit un retour qu'après la fin du geste de défilement et la stabilisation de l'élément. En utilisant les événements de défilement scroll-snap, en particulier scrollsnapchanging, vous pouvez fournir des commentaires en temps réel pendant le défilement, en mettant en évidence la cible d'alignement en attente avant que l'utilisateur ne relâche son doigt ou sa souris.

Implémentation

1. Détecter scrollsnapchanging

Associez un écouteur d'événements pour scrollsnapchanging au conteneur de défilement. Cet événement se déclenche lorsque le navigateur détermine qu'une nouvelle cible d'alignement est susceptible d'être sélectionnée.

const container = document.querySelector('#gallery');
const thumbnails = document.querySelectorAll('.thumbnail');
const items = document.querySelectorAll('.gallery-item');

container.addEventListener('scrollsnapchanging', (event) => {
  // Highlight pending snap target during scroll for real-time feedback.
  const pendingTarget = event.snapTargetInline;
  const index = [...items].indexOf(pendingTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Use lightweight class toggle to avoid layout thrashing during rapid events.
  // Note: aria-current is NOT toggled here. It tracks the settled "current"
  // item, which is updated in the scrollsnapchange handler below.
  thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
  thumbnails[index].classList.add('pending');
});

Cet exemple utilise snapTargetInline, car la galerie défile horizontalement. Si votre conteneur de défilement défile verticalement, utilisez plutôt snapTargetBlock.

2. Détecter scrollsnapchange

Pour finaliser l'état lorsque le geste de défilement est terminé et que l'élément s'aligne, écoutez l'événement scrollsnapchange. Cette étape est nécessaire pour établir l'état actif final.

container.addEventListener('scrollsnapchange', (event) => {
  // Promote pending state to active on scroll completion.
  const snappedTarget = event.snapTargetInline;
  const index = [...items].indexOf(snappedTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Establish final active state and clean up pending.
  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[index].classList.add('active');
  thumbnails[index].setAttribute('aria-current', 'true');
});

3. État initial de la synchronisation

Lorsque la page se charge, la position de défilement peut être restaurée par le navigateur (par exemple, via l'historique ou un lien d'ancrage). Ni les événements scrollsnapchange, ni les événements scroll ne se déclencheront automatiquement. Exécutez une vérification géométrique ponctuelle pour synchroniser l'UI avec la position de défilement initiale.

// Note: For item.offsetLeft to be relative to the container, 
// the container MUST be the offsetParent (e.g., `position: relative`).
const findClosestItemIndex = () => {
  // Center-distance assumes scroll-snap-align: center on items.
  // For start-aligned snap, compare scrollLeft to item.offsetLeft directly.
  const containerCenter = container.scrollLeft + container.clientWidth / 2;
  let closestIndex = 0;
  let minDistance = Infinity;

  items.forEach((item, index) => {
    const itemCenter = item.offsetLeft + item.offsetWidth / 2;
    const distance = Math.abs(containerCenter - itemCenter);
    if (distance < minDistance) {
      minDistance = distance;
      closestIndex = index;
    }
  });
  return closestIndex;
};

const initActiveItem = () => {
  const closestIndex = findClosestItemIndex();
  if (!thumbnails[closestIndex]) return;

  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[closestIndex].classList.add('active');
  thumbnails[closestIndex].setAttribute('aria-current', 'true');
};

if (document.readyState === 'complete') {
  initActiveItem();
} else {
  window.addEventListener('load', initActiveItem, { once: true });
}

Stratégies de remplacement

Pour les navigateurs qui ne sont pas compatibles avec scrollsnapchanging, l'UI ne fournit pas de retour immédiat pendant le geste de défilement par défaut, et l'UI associée se désynchronise du contenu.

OBLIGATOIRE : Fournissez une solution de remplacement pour les navigateurs non compatibles, sinon l'UI associée se désynchronisera du contenu.

À FAIRE : simulez la nature en temps réel de scrollsnapchanging avec un écouteur d'événements scroll associé à requestAnimationFrame et des calculs de distance géométrique pour déterminer la cible d'alignement la plus proche pendant que l'utilisateur fait défiler l'écran.

if ('onscrollsnapchanging' in Element.prototype) {
  // Use native scroll snap events
} else {
  // Fallback: use scroll + requestAnimationFrame for eager feedback
  // (assumes the same container, thumbnails, items defined in step 1)
  let scrollTimeout;
  let rafId = null;

  const promotePendingToActive = () => {
    const closestIndex = findClosestItemIndex();
    if (!thumbnails[closestIndex]) return;
    thumbnails.forEach((thumb) => {
      thumb.classList.remove('pending', 'active');
      thumb.removeAttribute('aria-current');
    });
    thumbnails[closestIndex].classList.add('active');
    thumbnails[closestIndex].setAttribute('aria-current', 'true');
  };

  container.addEventListener('scroll', () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      rafId = null;
      const closestIndex = findClosestItemIndex();
      if (!thumbnails[closestIndex]) return;

      // DO NOT forget to clean up stale pending classes
      thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
      thumbnails[closestIndex].classList.add('pending');
    });

    // Debounce fallback for browsers that don't support scrollend
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(promotePendingToActive, 100);
  }, { passive: true });

  // Fallback: use Baseline `scrollend` event to promote pending to active cleanly where supported
  container.addEventListener('scrollend', () => {
    clearTimeout(scrollTimeout);
    promotePendingToActive();
  });
}

La solution de remplacement géométrique scroll + requestAnimationFrame émule fidèlement le comportement de la prédiction d'alignement native, y compris la gestion correcte du défilement programmatique. Étant donné que les fonctions telles que scrollIntoView déclenchent naturellement des événements scroll lors de leur exécution, l'UI reste synchronisée de manière fluide tout au long de l'animation de défilement, sans nécessiter de logique personnalisée supplémentaire.