Vegen om te verwijderen

Swipe-to-remove-patronen komen vaak voor in mobiele apps, maar kunnen lastig zijn om netjes te implementeren op het web. Met CSS Scroll Snap kun je een soepele, native aanvoelende swipe-interactie maken die rechtstreeks is gekoppeld aan de scroll-engine van de browser. Dit zorgt voor hoge prestaties en op fysica gebaseerd momentum zonder dat er een complexe JavaScript-bibliotheek voor gebaren nodig is.

Hetzelfde patroon werkt voor elke swipe met één actie (verwijderen, archiveren, markeren als gelezen, snoozen). De actiebeelden veranderen, de mechanica niet.

Als je in plaats daarvan een horizontale swipe wilt om een set actieknoppen of een zijmenu te openen zonder automatisch een actie uit te voeren tijdens de swipe, ga je naar Swipen om te onthullen.

Hoe wordt dit geïmplementeerd

De component heeft 2 lagen: een lijst (de <ul>) en de items daarin. Elk item is gestructureerd als een buitenste <li>, een binnenste scroll-track (de scrollcontainer met de uitlijnpunten) en een content-element (de zichtbare rij). De onthulde UI van de actie (prullenbakicoon, archieflabel, enzovoort) bevindt zich aan weerszijden van de content. De lijst is eigenaar van de gedeelde bedrading (lazy item setup, nieuwe items ophalen). Elk item is eigenaar van de eigen swipe-detectie.

Stap 1: Markeer de lijst met nummer en content

<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>

Stap 2: Stel de track in als een horizontale snapcontainer met 3 snappunten

De track heeft 3 kolommen over de volledige breedte: een afstandshouder links (::before), de content en een afstandshouder rechts (::after). Als je vastzet op een afstandshouder, staat de content helemaal buiten het scherm. Dit is de rustpositie na een uitgevoerde swipe.

De trackconfiguratie is afgeschermd achter een .is-initialized-klasse in het lijstitem. Voordat JS de rij upgradet, wordt het item gerenderd als platte content zonder horizontale schuifbalk. De klasse wordt in stap 4 toegevoegd nadat het JavaScript dat swipes herkent, is gekoppeld. Zo kan de gebruiker niet swipen voordat de functionaliteit klaar is.

.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;
}

Stap 3: Voeg de actie-iconen toe aan het lijstitem

De actiekleur staat op de afstandhouders (stap 2). Het actie-icoon staat op het lijstitem zelf, verankerd aan de linker- en rechterrand van de rij.

De plaatsing, grootte en beweging hieronder zijn een startsuggestie, geen vereiste. Pas de icoongrootte, randinvoegingen, drempelwaarde-pop-schaal, overgangsduur en zelfs de keuze van pseudo-elementen versus echte DOM-nodes aan zodat ze bij je ontwerp passen. De enige mechanische vereiste is dat het icoon achter de content staat (zodat de content het icoon kan bedekken voordat je swipet) en in het lijstitem (zodat het niet scrollt met de afstandhouder). De rest is een kwestie van smaak.

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

Stap 4: Het vastleggingsgebaar detecteren met IntersectionObserver

Gebruik IntersectionObserver die op de baan staat en de content observeert. Terwijl de gebruiker swipet, daalt de snijpuntverhouding van de content met de track. We gebruiken 2 drempelwaarden:

  • activateThreshold: een hoge verhouding (bijvoorbeeld 0,8). Als het zichtbare gedeelte van de content hieronder komt, is de gebruiker het visuele activeringspunt gepasseerd. Zet de mogelijkheid voor icoonpop-ups aan of uit.
  • commitThreshold: een lage verhouding (bijv. 0,2). Als het zichtbare gedeelte hieronder daalt, heeft de gebruiker zich gecommitteerd. Start de verwijderingsanimatie onmiddellijk, zonder te wachten tot het vastklikgebaar helemaal is voltooid. De samenvouwende rij gaat op in de voortdurende swipebeweging van de gebruiker. Dit voelt responsiever aan dan wachten tot de snap is geland voordat er wordt gereageerd.

Hier worden nog 2 problemen behandeld:

  • Lazy instellen per item: één buitenste IntersectionObserver met de root in de viewport stuurt de instelling en het starten/stoppen van de binnenste swipe-observers aan. Items worden alleen de eerste keer dat ze in beeld komen, gekoppeld en voor items die van het scherm worden gescrold, wordt de swipe-observer onderbroken. Zo blijft het aantal actieve waarnemers beperkt en worden er geen lay-outafhankelijke waarden (zoals clientWidth) gelezen voordat het item is gerenderd.
  • Dynamische items: echte lijsten groeien in de loop van de tijd (eerste weergave, oneindig scrollen, serverpush). Een MutationObserver op de <ul> registreert alle nieuw toegevoegde items bij de buitenste waarnemer.
// 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);

Stap 5: Gebruik de actie en het label die bij uw use case passen

Voor andere varianten dan verwijdering veranderen alleen de visuele elementen en de hoofdtekst van de commit-handler:

  • Archiveren: groene/blauwe achtergrond, archieficoon, het item naar een archieflijst verplaatsen in plaats van het te verwijderen.
  • Markeren als gelezen: gedempte achtergrond, vinkje, itemstatus updaten en opnieuw renderen (of gewoon een .unread-klasse verwijderen).
  • Snoozen: blauwe achtergrond, klokicoon, verbergen tot een gekozen tijd.

De scroll-/uitlijn-/observatiemechanismen zijn ongewijzigd.

Verschillende acties per swiperichting

Eén rij kan ook 2 verschillende acties tonen: één voor naar links swipen en één voor naar rechts swipen (bijvoorbeeld rechts archiveren en links verwijderen), zoals veel systeemeigen e-mail-apps doen. De mechanismen voor scrollen, uitlijnen en vastleggen veranderen helemaal niet. Het enige wat verandert, is wat er gebeurt op het moment van vastleggen.

In stap 4 roept de commit-branch altijd removeItem(...) aan. Als je 2 acties wilt ondersteunen, kies je de handler op basis van direction:

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

Een opmerking over de naamgeving: removeItem is vernoemd naar de destructieve case, maar de functie die wordt uitgevoerd (de hoogte van de rij samenvouwen, de content van het scherm schuiven en de node verwijderen) is eigenlijk een algemene routine voor 'deze rij is klaar, animeer deze weg'. Dit werkt net zo goed voor archiveren, markeren als gelezen of snoozen. De rij verdwijnt in elk geval uit deze lijst. Als je handlers niets verwijderen (bijvoorbeeld als ze allebei het item ergens anders naartoe verplaatsen), wijzig je de naam in iets neutraals zoals dismissItem, zodat de code correct wordt gelezen.

Als je de 2 acties visueel wilt onderscheiden, plaats je een kleur en icoon voor elke richting op het lijstitem. Daarna kleur je het spoor met een gesplitst kleurverloop en de 2 pseudo-elementiconen van dezelfde variabelen.

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

Met deze instelling hebben de afstandhouders geen eigen background-color meer nodig (het verloop van de track zorgt voor de onthulling), dus je kunt de regel background-color op .SwipeableList-track::before, ::after uit stap 2 weglaten als je deze variant met dubbele actie gebruikt.

Best practices en valkuilen

  • WEL DOEN: gebruik mandatory snap, niet proximity. Met proximity kan de rij gedeeltelijk gescrold blijven, waardoor de actieachtergrond half zichtbaar blijft.
  • WEL DOEN: Stel overscroll-behavior-x: none in op de track. Zonder deze functie kan een over-swipe het terugnavigatiegebaar van de browser activeren op iOS/Android.
  • WEL: Voer de actie uit bij een drempelwaarde voordat de snap is voltooid (bijvoorbeeld commitThreshold ≈ 0.2) in plaats van te wachten tot de content helemaal van het scherm is. Zo kan de verwijderingsanimatie tijdens het gebaar worden gestart, wat veel responsiever aanvoelt dan wachten tot de snap is geland.
  • WEL: Stel elk item in vanuit een buitenste viewport IntersectionObserver in plaats van elk item te koppelen wanneer de pagina wordt geladen. Zo voorkom je dat lay-outafhankelijke waarden (clientWidth, enzovoort) worden gelezen voordat items zijn gerenderd en blijft het aantal actieve waarnemers evenredig aan wat de gebruiker daadwerkelijk kan zien.
  • Gebruik WEL een MutationObserver in de lijst als items dynamisch worden toegevoegd (eerste weergave nadat gegevens zijn geladen, oneindig scrollen, serverpush). Zonder deze functie worden items die na het laden van de pagina worden toegevoegd, niet gekoppeld.
  • Vertrouw NIET op pointerdown/pointermove/pointerup om een handmatige transformatie uit te voeren. Je verliest momentum, snap-back, toetsenbordtoegankelijkheid en verwerking van verminderde beweging die de browser je kosteloos biedt.
  • WEL: Bevestig destructieve acties als dat nodig is. Overweeg voor Verwijderen een toastbericht Ongedaan maken te tonen nadat de swipe is afgerond. Het gebaar is snel en kan makkelijk per ongeluk worden geactiveerd.
  • WEL: Zorg dat de scrolltrack focusbaar is, toegankelijk is via het toetsenbord en dat er een visuele focusmogelijkheid is.
  • Zorg voor toegankelijke alternatieven voor relevante acties die door de swipe worden geactiveerd (bijvoorbeeld een zichtbare knop, een contextmenu of de bewerkingsmodus).

Reservestrategieën

Alle nieuwere functies die worden gebruikt, zijn niet essentieel voor de functionaliteit of hebben robuuste alternatieven die nu betrouwbaar kunnen worden gebruikt.

Reserve voor overscroll-behavior

Voor deze toepassing is geen reserve-oplossing nodig. Hoewel overscroll-behavior een interoperabiliteitsprobleem heeft dat zich voordoet in containers zonder scrollbare overloop, is de track hier altijd horizontaal scrollbaar (3 kolommen met volledige breedte in een container met een breedte van 100%). De property gedraagt zich dus consistent in verschillende browsers en het swipegebaar wordt betrouwbaar ingesloten.

Reserve voor scrollbar-width

Verborgen scrollbalken zijn een visuele verbetering, niet het mechanisme dat swipen om te verwijderen mogelijk maakt. Als je Baseline-doel geen scrollbar-width bevat, scrollt, snapt, detecteert en verwijdert de rij nog steeds correct. De niet-ondersteunde functionaliteit toont misschien alleen een horizontale scrollbalk. Als uw product verborgen scrollbalken vereist in oudere browsers die zijn afgeleid van WebKit, kunt u een nauwkeurig gerichte ::-webkit-scrollbar { display: none; }-regel toevoegen voor de swipe-track.

Reserve voor scroll-initial-target

Als je Baseline-doel geen scroll-initial-target bevat, scroll je de track programmatisch naar de content in setupItem. Detecteren met CSS.supports:

// 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.
}

De volgorde van de gesprekken is belangrijk. De programmatic scroll MOET worden uitgevoerd:

  1. AFTER .is-initialized is toegevoegd aan het lijstitem (de klasse maakt van de track een scrollcontainer; het instellen van scrollLeft op een niet-scrollbaar element is een no-op).
  2. VOORDAT de binnenste IntersectionObserver uit stap 4 wordt bevestigd. Anders wordt de eerste programmatische scroll voorbij de linkerafstandshouder waargenomen als een swipe en wordt de commit-handler onmiddellijk geactiveerd.

De instelling van de buitenste viewport-observer (stap 4) maakt dit betrouwbaar: setupItem wordt uitgevoerd nadat het item is gerenderd, dus track.clientWidth retourneert een echte waarde in plaats van 0.

Sommige browsers (met name Safari) resetten ook de scrollpositie van de snap-container wanneer het formaat van de track wordt aangepast (URL-balk tonen/verbergen, formaat van viewport aanpassen, containerquery's, enzovoort). Gebruik een ResizeObserver op elke track om de scroll opnieuw toe te passen. Beperk dit met dezelfde CSS.supports-check: als scroll-initial-target wordt ondersteund, herstelt de browser zelf het scrollen bij het aanpassen van het formaat.

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.
}

Hef de observatie van de track op voordat de hoogteanimatie van de rij wordt uitgevoerd in removeItem, anders wordt de callback voor formaat aanpassen opnieuw geactiveerd door de hoogtewijziging tijdens de verwijdering. Voeg dit toe naast de bestaande viewportObserver.unobserve(item) in de commit-branch:

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