Zum Entfernen wischen

Wischgesten zum Entfernen sind in mobilen Anwendungen üblich, können aber im Web schwierig zu implementieren sein. Mit CSS Scroll Snap können Sie eine flüssige, native Wischinteraktion erstellen, die direkt in die Scroll-Engine des Browsers eingebunden wird. Das sorgt für eine hohe Leistung und physikbasierte Dynamik, ohne dass eine komplexe JavaScript-Gestenbibliothek erforderlich ist.

Dasselbe Muster funktioniert für jede Wischgeste mit einer einzelnen Aktion (Entfernen, Archivieren, Als gelesen markieren, Zurückstellen). Die Darstellung der Aktion ändert sich, die Mechanik nicht.

Wenn Sie stattdessen möchten, dass durch horizontales Wischen eine Reihe von Aktionsbuttons oder ein seitliches Menü geöffnet wird, ohne dass während des Wischens automatisch eine Aktion ausgeführt wird, lesen Sie den Abschnitt Wischen, um Elemente einzublenden.

Implementierung

Die Komponente hat zwei Ebenen: eine Liste (<ul>) und die darin enthaltenen Elemente. Jedes Element ist als äußeres <li>, als innere Scrollspur (der Scrollcontainer mit den Einrastpunkten) und als Inhaltselement (die sichtbare Zeile) strukturiert. Die angezeigte Benutzeroberfläche der Aktion (Papierkorbsymbol, Archivlabel usw.) befindet sich auf beiden Seiten des Inhalts. Die Liste hat eine gemeinsame Verkabelung (Lazy-Item-Einrichtung, Aufnahme neu hinzugefügter Elemente). Jedes Element hat eine eigene Wisch-Erkennung.

Schritt 1: Liste mit Track und Inhalt auszeichnen

<ul class="SwipeableList">
  <li id="list-item-1" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item One</div>
    </div>
  </li>
  <li id="list-item-2" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item Two</div>
    </div>
  </li>
  <!-- ...more items... -->
</ul>

Schritt 2: Track als horizontalen Snap-Container mit drei Snap-Punkten konfigurieren

Der Track hat drei Spalten mit voller Breite: einen linken Abstandshalter (::before), den Inhalt und einen rechten Abstandshalter (::after). Wenn der Inhalt an einem Abstandshalter einrastet, ist er vollständig außerhalb des Bildschirms. Das ist die Ruheposition nach einem abgeschlossenen Wischen.

Die Track-Konfiguration ist hinter einer .is-initialized-Klasse im Listenelement verborgen. Bevor JS die Zeile aktualisiert, wird das Element nur als einfacher Inhalt ohne horizontale Bildlaufleiste gerendert. Die Klasse wird in Schritt 4 hinzugefügt, nachdem das JavaScript, das Wischbewegungen erkennt, eingebunden wurde. So wird verhindert, dass der Nutzer wischt, bevor die Funktion bereit ist.

.SwipeableList {
  list-style: none;
}

.SwipeableList-item {
  /* Establishes a containing block for the absolutely positioned action
     icons (Step 3) and clips overflow during the row's removal
     animation (Step 4). */
  contain: content;
}

/* The track only becomes a scroll snap container after JS upgrades
   the row by adding `.is-initialized` (see Step 4). */
.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Three full-width columns: left spacer | content | right spacer.
     Width is 100% of the track, so each column fills the viewport row. */
  display: grid;
  grid-template-columns: 100% 100% 100%;

  /* Horizontal scroll only; vertical overflow is clipped so the
     reveal stays inside the row. */
  overflow: scroll clip;

  /* Prevent the swipe from chaining into the page scroll or browser
     back-gesture on iOS/Android. */
  overscroll-behavior-x: none;

  /* Hide the scrollbar; the gesture is the affordance. */
  scrollbar-width: none;

  /* `mandatory` ensures the track always rests on a snap point
     (spacer or content), never partially scrolled. */
  scroll-snap-type: x mandatory;
}

/* Spacers act as the left and right snap targets, AND carry the action's
   reveal color. As the user swipes, the colored spacer slides into view,
   which is what the user sees behind the content. */
.SwipeableList-item.is-initialized .SwipeableList-track::before,
.SwipeableList-item.is-initialized .SwipeableList-track::after {
  content: '';

  /* `scroll-snap-align` is required to make this a valid snap target,
     but the specific value (`start`/`center`/`end`) doesn't matter here
     because each snap point spans the full width of the scroll
     container, so all alignments resolve to the same resting position. */
  scroll-snap-align: start;

  /* `hsl(0 65% 50%)` is an example value; pick whatever fits your
     design (red here signals "delete"; use a different color for
     archive, mark-as-read, etc.). */
  background-color: hsl(0 65% 50%);
}

.SwipeableList-content {
  /* The content sits above the action icons (Step 3), so it covers
     them until the user swipes. */
  position: relative;
  z-index: 2;

  /* Required to make the content a valid snap target (its resting
     position). As with the spacers above, the specific value doesn't
     matter because the snap points are full-width. */
  scroll-snap-align: start;

  /* The content must paint over the revealed spacer color. */
  background: Canvas;

  /* Row separator (example value; customize to taste). */
  border-bottom: 1px solid #eee;
}

/* Gate `scroll-initial-target` behind `.is-initialized` so it only
   applies once the track is actually a scroll container. Setting it on
   the content before then would let the property walk up to the nearest
   scrollable ancestor (typically the document) and shift the page's
   initial scroll position to bring this row's content into view. With
   the gate, the rule is only live when the row's own track can satisfy
   it, so the initial scroll happens inside the track as intended. */
.SwipeableList-item.is-initialized .SwipeableList-content {
  scroll-initial-target: nearest;
}

/* The track is the focusable scroll container, but its overflow is clipped
   so a default focus ring on the track itself would be invisible. Project
   the focus affordance onto the content element (which paints above the
   track) using `:focus-visible` on the track. */
.SwipeableList-track:focus-visible .SwipeableList-content {
  outline: auto;
  outline-offset: -2px;
}

Schritt 3: Aktionssymbole zum Listenelement hinzufügen

Die Aktionsfarbe wird auf den Abstandshaltern angezeigt (Schritt 2). Das Aktionssymbol befindet sich auf dem Listenelement selbst und ist am linken und rechten Rand der Zeile verankert.

Die unten aufgeführten Platzierungen, Größen und Bewegungen sind ein Vorschlag für den Einstieg, keine Anforderung. Passen Sie die Symbolgröße, die Randabstände, die Skalierung für das Schwellenwert-Pop-up, die Übergangsdauer und sogar die Auswahl von Pseudoelementen im Vergleich zu echten DOM-Knoten an, um sie an Ihr Design anzupassen. Die einzige mechanische Anforderung ist, dass sich das Symbol hinter dem Inhalt (damit der Inhalt es vor dem Wischen verdecken kann) und innerhalb des Listenelements befindet (damit es nicht mit dem Abstandshalter scrollt). Alles andere ist Geschmackssache.

/* Action icons painted on the list item. They're absolutely positioned
   inside the row (which is a containing block thanks to `contain: content`
   on `.SwipeableList-item` from Step 2) and sit at z-index 1, so the
   content element (z-index 2) covers them until the user swipes far
   enough. */
.SwipeableList-item.is-initialized::before,
.SwipeableList-item.is-initialized::after {
  /* Inline an SVG as the action icon. Replace this with whatever icon
     fits the action (archive, checkmark, clock, etc.). The `fill='white'`
     is baked into the SVG so it contrasts with the red spacer background;
     adjust if your background color is light. */
  --action-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3H9zm0 5h2v9H9V8zm4 0h2v9h-2V8z'/></svg>");

  content: '';
  position: absolute;
  z-index: 1;

  /* The size (`width`) and insets (`left`/`right` below) are example
     values, tune them to match your row height and visual weight.
     The icon fills its box via `background-size: contain`, so changing
     `width` resizes it. */
  width: 1.5em; /* example value, adjust to taste */
  aspect-ratio: 1;
  top: 50%;
  translate: 0 -50%;

  /* Smooth transitions for the icon's visual states: the activate-point
     pop (`scale`, see `.is-activating` below) and the removal fade
     (`scale` + `opacity`, see `.is-removing` below). 0.2s is an example
     duration. */
  transition: scale 0.2s ease, opacity 0.2s ease;

  background: var(--action-icon) center / contain no-repeat;
}
/* Inset from the row edge (example values; adjust to match your layout). */
.SwipeableList-item.is-initialized::before { left: 1.5em; }
.SwipeableList-item.is-initialized::after  { right: 1.5em; }

/* Activating pop: scale the icon up when the user is past the visual
   activate point, so the row's affordance feels reactive. Toggled by JS
   in Step 4. */
.SwipeableList-item.is-activating::before,
.SwipeableList-item.is-activating::after {
  scale: 1.333;
}

/* Removal affordance: fade and shrink the icons as the row collapses.
   Driven by the `is-removing` class added in Step 4 at commit time;
   the existing `transition` on the icons animates the change. */
.SwipeableList-item.is-removing::before,
.SwipeableList-item.is-removing::after {
  scale: 0.5;
  opacity: 0;
}

/* Only show the icon on the *leading* side of the swipe; hide the
   trailing-side one. `data-swipe-direction` is set by JS in Step 4. */
.SwipeableList-item.is-activating[data-swipe-direction="left"]::after,
.SwipeableList-item.is-activating[data-swipe-direction="right"]::before {
  visibility: hidden;
}

Schritt 4: Commit-Geste mit IntersectionObserver erkennen

Verwenden Sie IntersectionObserver, das auf dem Track basiert, und beobachten Sie den Inhalt. Wenn der Nutzer wischt, sinkt das Schnittverhältnis des Inhalts mit dem Track. Wir verwenden zwei Grenzwerte:

  • activateThreshold: ein hohes Verhältnis (z. B. 0,8). Wenn der sichtbare Teil des Inhalts darunter fällt, hat der Nutzer den visuellen Aktivierungspunkt überschritten. Schalten Sie die Option „Pop-up-Symbol“ ein oder aus.
  • commitThreshold: ein niedriges Verhältnis (z. B. 0,2). Wenn der sichtbare Teil darunter fällt, hat sich der Nutzer entschieden. Starten Sie die Animation zum Entfernen sofort, ohne darauf zu warten, dass die Einrastgeste vollständig abgeschlossen ist. Die minimierte Zeile fügt sich in die fortlaufende Wischbewegung des Nutzers ein. Das fühlt sich reaktionsschneller an, als auf das Einrasten zu warten, bevor reagiert wird.

Hier werden zwei weitere Probleme behandelt:

  • Lazy-Einrichtung pro Element: Eine einzelne äußere IntersectionObserver, die im Darstellungsbereich verankert ist, steuert die Einrichtung und das Starten/Stoppen der inneren Swipe-Beobachter. Elemente werden nur beim ersten Scrollen in die Ansicht eingebunden. Bei Elementen, die aus dem Bildschirm gescrollt werden, wird der Swipe-Observer pausiert. Dadurch wird die Anzahl der aktiven Beobachter begrenzt und es wird vermieden, dass layoutabhängige Werte (wie clientWidth) gelesen werden, bevor das Element gerendert wurde.
  • Dynamische Elemente: Reale Listen werden mit der Zeit länger (erstes Rendern, unendliches Scrollen, Server-Push). Ein MutationObserver auf dem <ul> registriert alle neu hinzugefügten Elemente beim äußeren Beobachter.
// Per-item handles. Populated when an item is first lazily wired up; read by
// the outer viewport observer to start/stop the inner observer as items enter
// and leave the viewport.
const swipeObservers = new WeakMap();

// Outer observer: drives the entire swipe lifecycle off viewport visibility.
// On first entry, lazily wires the item up (`setupItem` reads layout-dependent
// values like `clientWidth`, which return 0 until the item is rendered). After
// setup, starts the inner observer. On exit, stops the inner observer so
// offscreen items don't track scroll positions.
const viewportObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const item = entry.target;
    if (entry.isIntersecting) {
      const handle = swipeObservers.get(item) ?? setupItem(item);
      handle.observer.observe(handle.content);
    } else {
      const handle = swipeObservers.get(item);
      if (handle) handle.observer.unobserve(handle.content);
    }
  }
});

function setupItem(item) {
  const track = item.querySelector('.SwipeableList-track');
  const content = track.querySelector('.SwipeableList-content');

  // Upgrade the row into "swipeable" mode. This is the gate for all the CSS
  // from Steps 2 and 3 (the track becomes a snap container, the action icons
  // appear). Done *before* the inner observer is attached so the snap
  // container exists by the time intersection callbacks can fire.
  item.classList.add('is-initialized');

  // Tunable thresholds. `activateThreshold` is the visual feedback point
  // (icon pops). `commitThreshold` is the point of no return: once the
  // content is past this point of being off-screen, we commit even if the
  // user releases mid-gesture and the track snaps back. A low value (~0.2)
  // commits before the snap settles, so the remove animation can start
  // during the swipe.
  const activateThreshold = 0.8;
  const commitThreshold = 0.2;

  // One inner observer per item, rooted at the track. Vertical scrolling of
  // the outer list moves root and target together, so the callback only fires
  // for the horizontal swipe.
  const observer = new IntersectionObserver((entries, observer) => {
    const entry = entries.at(-1);
    const ratio = entry.intersectionRatio;

    // Direction the user is swiping toward. A positive offset from the
    // track's left edge means the content has been pulled right (left
    // spacer revealed), so the leading icon is on the left.
    const direction = (entry.boundingClientRect.x - entry.rootBounds.x) > 0
      ? 'left'
      : 'right';

    if (ratio < commitThreshold) {
      // The IO entry's boundingClientRect is the last reliable measurement
      // before the animation starts; reuse it for both the pre-collapse
      // height and the slide-off translate distance.
      removeItem(item, content, direction, entry);
      viewportObserver.unobserve(item);
      observer.disconnect();
      return;
    }

    // Scale up the leading icon while the content is past the activate
    // point; restore it at rest.
    item.classList.toggle('is-activating', ratio < activateThreshold);

    // Hold the previous direction at rest so the icon's exit animation
    // finishes on the side the user was swiping toward.
    if (entry.boundingClientRect.x !== entry.rootBounds.x) {
      item.dataset.swipeDirection = direction;
    }
  }, {
    root: track,
    threshold: [commitThreshold, activateThreshold],
  });

  // Return the handle without starting observation; the outer viewport observer
  // calls `observer.observe(content)` once the item is in view.
  const handle = {observer, content};
  swipeObservers.set(item, handle);
  return handle;
}

async function removeItem(item, content, direction, entry) {
  const opts = { duration: 300, easing: 'ease', fill: 'forwards' };

  const rect = entry.boundingClientRect;
  // Content's pixel offset from the track's left edge.
  const x = rect.x - entry.rootBounds.x;
  // Pixel distance the content needs to travel to be fully out of view.
  const translate = direction === 'left'
    ? rect.width - x
    : -(x + rect.width);

  // Use a combination of CSS transitions (for declarative styles) and
  // WAAPI animations (for computed values) to remove the element,
  // then await the completion of all of them.
  // Note: the content translate animation is important because the
  // height-collapse animation can otherwise finish before the browser's
  // smooth scroll-snap has scrolled the content fully off-screen.
  item.classList.add('is-removing');
  item.animate([{ height: `${rect.height}px` }, { height: '0px' }], opts);
  content.animate([{ translate: `${translate}px` }], opts);
  await Promise.allSettled(
    item.getAnimations({ subtree: true }).map((a) => a.finished),
  );

  // Safari has a scroll-latching bug: removing the node while the swipe
  // gesture's momentum is still resolving causes the next item (which
  // slides up into this one's place) to inherit the scroll and
  // immediately scroll itself off-screen. Detect Safari via
  // `GestureEvent` (a Safari-only API) and defer the actual DOM removal
  // until the gesture has fully settled. The 5s delay is conservative;
  // anything longer than the momentum tail is fine. Mark the row inert
  // so it can't be interacted with during the delay.
  if (globalThis.GestureEvent) {
    item.inert = true;
    setTimeout(() => item.remove(), 5000);
  } else {
    item.remove();
  }
}

function setupList(list) {
  // Observe items already in the list.
  for (const item of list.children) {
    if (item.matches('.SwipeableList-item')) {
      viewportObserver.observe(item);
    }
  }

  // Pick up items added later (initial render after data loads, infinite
  // scroll, server push). Removals don't need MutationObserver handling —
  // the commit branch above already unobserves the item before removing it
  // from the DOM.
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.matches('.SwipeableList-item')) {
          viewportObserver.observe(node);
        }
      }
    }
  }).observe(list, { childList: true });
}

document.querySelectorAll('.SwipeableList').forEach(setupList);

Schritt 5: Aktion und Label verwenden, die zu Ihrem Anwendungsfall passen

Bei anderen Varianten als dem Entfernen ändern sich nur die visuellen Elemente und der Text des Commit-Handlers:

  • Archivieren: grüner/blauer Hintergrund, Archivsymbol, das Element wird in eine Archivliste verschoben, anstatt es zu entfernen.
  • Als gelesen markieren: gedämpfter Hintergrund, Häkchensymbol, Elementstatus aktualisieren und neu rendern (oder einfach eine .unread-Klasse entfernen).
  • Zurückstellen: blauer Hintergrund, Uhrensymbol, bis zu einem ausgewählten Zeitpunkt ausblenden.

Die Scroll-/Snap-/Beobachtungsmechanismen bleiben unverändert.

Unterschiedliche Aktionen je nach Wischrichtung

Eine einzelne Zeile kann auch zwei verschiedene Aktionen auslösen: eine für das Wischen nach links und eine für das Wischen nach rechts (z.B. „Archivieren“ beim Wischen nach rechts und „Löschen“ beim Wischen nach links), wie es bei vielen nativen E-Mail-Apps der Fall ist. Die Mechanismen für Scrollen, Einrasten und Commit-Erkennung ändern sich überhaupt nicht. Es ändert sich nur, was beim Commit passiert.

In Schritt 4 ruft der Commit-Zweig immer removeItem(...) auf. Wenn Sie zwei Aktionen unterstützen möchten, wählen Sie den Handler anhand von direction aus:

if (ratio < commitThreshold) {
  const handler = direction === 'left' ? archiveItem : removeItem;
  handler(item, content, direction, entry);
  viewportObserver.unobserve(item);
  observer.disconnect();
  return;
}

Hinweis zur Benennung: removeItem ist nach dem destruktiven Fall benannt, aber die Funktion, die es ausführt (die Höhe der Zeile minimieren, den Inhalt vom Bildschirm schieben und dann den Knoten löschen), ist eigentlich eine allgemeine Routine, die besagt: „Diese Zeile ist fertig, animiere sie weg“. Das funktioniert auch beim Archivieren, Markieren als gelesen oder Zurückstellen – die Zeile wird in jedem Fall aus dieser Liste entfernt. Wenn Ihre Handler nichts entfernen, sondern das Element beispielsweise an eine andere Stelle verschieben, benennen Sie es in etwas Neutrales wie dismissItem um, damit der Code korrekt gelesen wird.

Damit die beiden Aktionen visuell unterscheidbar sind, weisen Sie dem Listenelement für jede Richtung eine Farbe und ein Symbol zu. Anschließend füllen Sie die Spur mit einem geteilten Farbverlauf und den beiden Pseudoelementsymbolen aus denselben Variablen.

.SwipeableList-item {
  /* Action color + icon per swipe direction. `--left-*` is revealed
     when the user swipes RIGHT (e.g., archive); `--right-*` is revealed
     when the user swipes LEFT (e.g., delete). */
  --left-action-color: hsl(140 50% 40%);
  --left-action-icon: url("…archive svg…");
  --right-action-color: hsl(0 65% 50%);
  --right-action-icon: url("…trash svg…");
}

.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Split reveal: left half of the scrollable area gets the left-action
     color, right half gets the right-action color. `background-attachment:
     local` makes the gradient's positioning area the scrollable area
     (3x the visible width), so the default size fills it and the 50% hard
     stop lines up exactly with the midpoint of the resting content column.
     The track's color also stays continuous behind the content as it
     translates off, so the leading-direction color shows the whole way. */
  background-image: linear-gradient(
    to right,
    var(--left-action-color) 50%,
    var(--right-action-color) 50%
  );
  background-attachment: local;
}

/* Per-side icons read from the same variables. */
.SwipeableList-item.is-initialized::before { background-image: var(--left-action-icon); }
.SwipeableList-item.is-initialized::after  { background-image: var(--right-action-icon); }

Bei dieser Konfiguration benötigen die Abstandshalter keine eigene Hintergrundfarbe mehr (der Farbverlauf der Spur übernimmt die Anzeige). Wenn Sie diese Variante mit zwei Aktionen verwenden, können Sie die Regel background-color für .SwipeableList-track::before, ::after aus Schritt 2 weglassen.

Best Practices und Fallstricke

  • VERWENDEN Sie den mandatory-Snap, nicht proximity. Bei proximity kann die Zeile teilweise gescrollt werden, sodass der Aktionshintergrund halb sichtbar bleibt.
  • DO set overscroll-behavior-x: none on the track. Ohne diese Einstellung kann ein zu weites Wischen die Rückwärtsnavigation des Browsers auf iOS/Android auslösen.
  • DO commit at a threshold before the snap settles (e.g., commitThreshold ≈ 0.2) rather than waiting for the content to be fully off-screen. Dadurch kann die Animation zum Entfernen während der Geste gestartet werden, was sich deutlich reaktionsschneller anfühlt, als auf das Einrasten zu warten.
  • DO drive per-item setup from an outer viewport IntersectionObserver rather than wiring every item up at page load. So wird vermieden, dass layoutabhängige Werte (clientWidth usw.) gelesen werden, bevor Elemente gerendert wurden. Außerdem bleibt die Anzahl der aktiven Beobachter proportional zu dem, was der Nutzer tatsächlich sehen kann.
  • Verwenden Sie ein MutationObserver für die Liste, wenn Elemente dynamisch hinzugefügt werden (erstes Rendering nach dem Laden der Daten, unendliches Scrollen, Server-Push). Ohne sie werden Elemente, die nach dem Seitenaufbau angehängt werden, nicht verknüpft.
  • Verlassen Sie sich NICHT auf pointerdown/pointermove/pointerup, um eine manuelle Transformation durchzuführen. Sie verlieren die Dynamik, das Zurückschnappen, die Tastaturbedienung und die Verarbeitung von reduzierten Bewegungen, die der Browser kostenlos bietet.
  • DO confirm destructive actions when appropriate. Für „Entfernen“ sollten Sie nach dem Wischen eine Toast-Benachrichtigung zum Rückgängigmachen anzeigen, da die Geste schnell und einfach versehentlich ausgelöst werden kann.
  • DO ensure the scroll track is focusable, keyboard accessible, and that there is a visual focus affordance.
  • DO provide accessible alternatives for any relevant actions triggered by the swipe (e.g., a visible button, context menu, or edit mode).

Fallback-Strategien

Alle neueren Funktionen, die verwendet werden, sind entweder nicht zentral für die Nutzung oder haben robuste Fallbacks, die jetzt zuverlässig verwendet werden können.

Fallback für overscroll-behavior

Für diesen Anwendungsfall ist kein Fallback erforderlich. overscroll-behavior hat zwar ein Interoperabilitätsproblem, das bei Containern ohne scrollbaren Überlauf auftritt, aber der Track hier ist immer horizontal scrollbar (drei Spalten mit voller Breite in einem Container mit 100 % Breite). Daher verhält sich die Property in allen Browsern einheitlich und die Wischgeste wird zuverlässig erkannt.

Fallback für scrollbar-width

Ausgeblendete Bildlaufleisten sind eine visuelle Verbesserung, nicht der Mechanismus, der das Entfernen durch Wischen ermöglicht. Wenn Ihr Baseline-Ziel scrollbar-width nicht enthält, wird die Zeile trotzdem korrekt gescrollt, eingerastet, die Bestätigung erkannt und entfernt. Bei der nicht unterstützten Version wird möglicherweise nur eine horizontale Scrollleiste angezeigt. Wenn für Ihr Produkt in älteren WebKit-basierten Browsern ausgeblendete Bildlaufleisten erforderlich sind, können Sie eine eng gefasste ::-webkit-scrollbar { display: none; }-Regel für den Swipe-Track hinzufügen.

Fallback für scroll-initial-target

Wenn Ihr Baseline-Ziel nicht scroll-initial-target enthält, scrollen Sie den Track programmatisch innerhalb von setupItem zum Inhalt. Mit CSS.supports erkennen:

// Hoist the feature detect so the conditional `ResizeObserver` below can be
// skipped entirely when the property is supported.
const needsScrollWorkaround = !CSS.supports('scroll-initial-target', 'nearest');

function setupItem(item) {
  // ...existing setup from Step 4...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
  }

  // ...attach the inner IntersectionObserver, etc.
}

Die Reihenfolge der Aufrufe ist wichtig. Das programmatische Scrollen MUSS ausgeführt werden:

  1. AFTER .is-initialized dem Listenelement hinzugefügt wurde (die Klasse macht den Track zu einem Scroll-Container; das Festlegen von scrollLeft für ein nicht scrollbares Element hat keine Auswirkungen).
  2. BEVOR die innere IntersectionObserver aus Schritt 4 angebracht wird. Andernfalls wird das anfängliche programmatische Scrollen über den linken Abstandshalter als „Wischen“ erkannt und der Commit-Handler wird sofort ausgelöst.

Die Einrichtung über den äußeren Viewport-Beobachter (Schritt 4) macht dies zuverlässig: setupItem wird ausgeführt, nachdem das Element gerendert wurde, sodass track.clientWidth einen echten Wert anstelle von 0 zurückgibt.

Einige Browser (insbesondere Safari) setzen die Scrollposition des Snap-Containers auch zurück, wenn sich die Größe des Tracks ändert (Ein-/Ausblenden der URL-Leiste, Größenänderung des Darstellungsbereichs, Containerabfragen usw.). Verwenden Sie für jeden Track einen ResizeObserver, um das Scrollen wieder anzuwenden. Verwenden Sie dieselbe CSS.supports-Prüfung. Wenn scroll-initial-target unterstützt wird, übernimmt der Browser die Wiederherstellung des Scrollens beim Ändern der Größe selbst.

const trackResizeObserver = needsScrollWorkaround
  ? new ResizeObserver((entries) => {
      for (const entry of entries) {
        entry.target.scrollLeft = entry.target.clientWidth;
      }
    })
  : null;

function setupItem(item) {
  // ...existing setup...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
    trackResizeObserver.observe(track);
  }

  // ...attach the inner IntersectionObserver, etc.
}

Beenden Sie die Beobachtung des Tracks, bevor die Höhenanimation der Zeile in removeItem ausgeführt wird. Andernfalls wird die Größenänderung beim Entfernen noch einmal ausgelöst. Fügen Sie dies zusammen mit dem vorhandenen viewportObserver.unobserve(item) im Commit-Zweig hinzu:

if (ratio < commitThreshold) {
  removeItem(item, content, direction, entry);
  viewportObserver.unobserve(item);
  if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
  observer.disconnect();
  return;
}