Echtzeit-Feedback zu Scroll Snap

Übersicht

Nutzer erwarten sofortiges visuelles Feedback, wenn sie mit UI-Elementen wie Karussells oder Galerien interagieren. Beim herkömmlichen Scroll Snap erhalten Sie erst nach Abschluss der Scroll-Geste und dem Einrasten des Elements Feedback. Mit Scroll-Snap-Ereignissen, insbesondere scrollsnapchanging, können Sie während der Scroll-Geste Echtzeit-Feedback geben und das ausstehende Snap-Ziel hervorheben, bevor der Nutzer die Berührung oder die Maus loslässt.

Implementierung

1. Auf scrollsnapchanging achten

Fügen Sie dem Scroll-Container einen Event-Listener für scrollsnapchanging hinzu. Dieses Ereignis wird ausgelöst, wenn der Browser feststellt, dass wahrscheinlich ein neues Einrastziel ausgewählt wird.

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');
});

In diesem Beispiel wird snapTargetInline verwendet, da die Galerie horizontal scrollt. Wenn der Scrollcontainer vertikal scrollt, verwenden Sie stattdessen snapTargetBlock.

2. Auf scrollsnapchange achten

Wenn Sie den Status abschließen möchten, wenn die Scroll-Geste abgeschlossen ist und das Element tatsächlich einrastet, warten Sie auf das scrollsnapchange-Ereignis. Dies ist erforderlich, um den endgültigen aktiven Zustand festzulegen.

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. Ausgangszustand synchronisieren

Wenn die Seite geladen wird, wird die Scrollposition möglicherweise vom Browser wiederhergestellt (z.B. über den Browserverlauf oder einen Ankerlink). Weder scrollsnapchange- noch scroll-Ereignisse werden automatisch ausgelöst. Führen Sie eine einmalige geometrische Prüfung durch, um die Benutzeroberfläche mit der ursprünglichen Scrollposition zu synchronisieren.

// 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 });
}

Fallback-Strategien

Bei Browsern, die scrollsnapchanging nicht unterstützen, wird die Benutzeroberfläche während der Scroll-Geste standardmäßig nicht sofort aktualisiert und die verknüpfte Benutzeroberfläche wird nicht mehr mit dem Inhalt synchronisiert.

OBLIGATORISCH: Stellen Sie einen Fallback für Browser ohne Unterstützung bereit, da die verknüpfte Benutzeroberfläche sonst nicht mehr mit dem Inhalt synchronisiert ist.

DO simulate the real-time nature of scrollsnapchanging with a scroll event listener coupled with requestAnimationFrame and geometric distance calculations to determine the closest snap target while the user is actively scrolling.

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();
  });
}

Das geometrische Fallback scroll + requestAnimationFrame emuliert das Verhalten der nativen Einrastvorhersage sehr genau, einschließlich der korrekten Verarbeitung von programmatischem Scrollen. Da Funktionen wie scrollIntoView während ihrer Ausführung natürlich scroll-Ereignisse auslösen, bleibt die Benutzeroberfläche während der Scroll-Animation synchron, ohne dass zusätzliche benutzerdefinierte Logik erforderlich ist.