Les modèles de balayage pour supprimer sont courants dans les applications mobiles, mais ils peuvent être difficiles à implémenter correctement sur le Web. En utilisant CSS Scroll Snap, vous pouvez créer une interaction de balayage fluide et native qui s'intègre directement au moteur de défilement du navigateur. Cela garantit des performances élevées et une inertie basée sur la physique sans nécessiter de bibliothèque de gestes JavaScript complexe.
Le même modèle fonctionne pour tout balayage à action unique (supprimer, archiver, marquer comme lu, mettre en attente). Les visuels des actions changent, mais pas les mécanismes.
Si vous souhaitez plutôt qu'un balayage horizontal ouvre un ensemble de boutons d'action ou un menu latéral sans valider automatiquement une action pendant le balayage, consultez Balayer pour afficher.
Mise en œuvre
Le composant comporte deux couches : une liste (<ul>) et les éléments qu'elle contient. Chaque élément est structuré comme un <li> externe, une piste de défilement interne (le conteneur de défilement avec les points d'ancrage) et un élément de contenu (la ligne visible). L'UI de l'action (icône de corbeille, libellé d'archive, etc.) s'affiche de chaque côté du contenu. La liste possède un câblage partagé (configuration d'éléments différée, récupération des éléments récemment ajoutés) ; chaque élément possède sa propre détection de balayage.
Étape 1 : Balisez la liste avec le titre et le contenu
<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>
Étape 2 : Configurez la piste en tant que conteneur d'alignement horizontal avec trois points d'alignement
La piste comporte trois colonnes de pleine largeur : un espaceur gauche (::before), le contenu et un espaceur droit (::after). L'alignement sur un espaceur signifie que le contenu est entièrement hors de l'écran, ce qui correspond à la position de repos après un balayage validé.
La configuration de la piste est contrôlée par une classe .is-initialized sur l'élément de la liste. Avant que JS ne mette à niveau la ligne, l'élément s'affiche simplement sous forme de contenu brut sans défilement horizontal. La classe est ajoutée à l'étape 4, une fois que le code JavaScript qui détecte les balayages a été connecté. Cela permet de s'assurer que l'utilisateur ne peut pas balayer l'écran avant que la fonctionnalité ne soit prête.
.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;
}
Étape 3 : Ajoutez les icônes d'action à l'élément de liste
La couleur d'action est appliquée aux espaces (étape 2). L'icône d'action se trouve sur l'élément de liste lui-même, ancré sur les bords gauche et droit de la ligne.
Le positionnement, la taille et le mouvement ci-dessous sont une suggestion de départ, et non une obligation. Ajustez la taille de l'icône, les marges intérieures, l'échelle de seuil, la durée de la transition et même le choix des pseudo-éléments par rapport aux nœuds DOM réels pour qu'ils correspondent à votre conception. La seule exigence mécanique est que l'icône se trouve derrière le contenu (afin que le contenu puisse la couvrir avant le balayage) et à l'intérieur de l'élément de liste (afin qu'elle ne défile pas avec l'espaceur). Tout le reste est une question de goût.
/* 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;
}
Étape 4 : Détecter le geste de validation avec IntersectionObserver
Utilisez IntersectionObserver enraciné au niveau de la piste, en observant le contenu. Lorsque l'utilisateur balaie l'écran, le ratio d'intersection du contenu avec la piste diminue. Nous utilisons deux seuils :
activateThreshold: ratio élevé (par exemple, 0,8). Lorsque la partie visible du contenu passe en dessous de ce seuil, l'utilisateur a dépassé le point d'activation visuel. Activer/Désactiver l'affordance de l'icône pop.commitThreshold: ratio faible (par exemple, 0,2). Lorsque la partie visible passe en dessous de ce seuil, l'utilisateur s'est engagé. Démarrez l'animation de suppression immédiatement, sans attendre que le geste d'ancrage soit complètement terminé. La ligne qui se réduit se fond dans le mouvement de balayage continu de l'utilisateur, ce qui donne une impression de plus grande réactivité que d'attendre que l'alignement se produise avant de réagir.
Deux autres problèmes sont traités ici :
- Configuration différée par élément : un seul
IntersectionObserverexterne ancré à la fenêtre d'affichage gère la configuration et le démarrage/l'arrêt des observateurs de balayage internes. Les éléments ne sont connectés que la première fois qu'ils apparaissent à l'écran, et l'observateur de balayage est mis en pause pour les éléments qui disparaissent de l'écran. Cela permet de limiter le nombre d'observateurs actifs et d'éviter de lire des valeurs dépendantes de la mise en page (commeclientWidth) avant l'affichage de l'élément. - Éléments dynamiques : les listes réelles s'allongent au fil du temps (affichage initial, défilement infini, server push). Un
MutationObserversur le<ul>enregistre tous les éléments nouvellement ajoutés auprès de l'observateur externe.
// 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);
Étape 5 : Utilisez l'action et le libellé adaptés à votre cas d'utilisation
Pour les variantes autres que la suppression, seuls les visuels et le corps du gestionnaire de commit changent :
- Archiver : arrière-plan vert/bleu, icône d'archive, déplacer l'élément vers une liste d'archives plutôt que de le supprimer.
- Marquer comme lu : arrière-plan atténué, icône de coche, mise à jour de l'état de l'élément et nouveau rendu (ou simplement suppression d'une classe
.unread). - Mettre en attente : fond bleu, icône d'horloge, masquer jusqu'à une heure choisie.
Les mécanismes de défilement, d'alignement et d'observation restent inchangés.
Différentes actions selon la direction du balayage
Une seule ligne peut également exposer deux actions différentes : une pour un balayage vers la gauche et une pour un balayage vers la droite (par exemple, "archiver" à droite, "supprimer" à gauche), comme le font de nombreuses applications de messagerie natives. Les mécanismes de défilement, de scroll-snap et de détection de commit ne changent pas du tout. Seul ce qui se passe au moment du commit change.
À l'étape 4, la branche de commit appelle toujours removeItem(...). Pour prendre en charge deux actions, sélectionnez le gestionnaire en fonction de direction :
if (ratio < commitThreshold) {
const handler = direction === 'left' ? archiveItem : removeItem;
handler(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
Remarque sur l'attribution de noms : removeItem est nommé pour le cas destructif, mais la fonction qu'il exécute (réduire la hauteur de la ligne, faire glisser le contenu hors de l'écran, puis supprimer le nœud) est en réalité une routine générique "cette ligne est terminée, animez-la". Cela fonctionne aussi bien pour l'archivage, le marquage comme lu ou la mise en attente. Dans tous les cas, la ligne disparaît de cette liste. Si vos gestionnaires ne suppriment rien (par exemple, s'ils déplacent tous les deux l'élément ailleurs), renommez-le avec un nom neutre comme dismissItem pour que le code soit lu correctement.
Pour que les deux actions soient visuellement distinctes, hissez une couleur et une icône pour chaque direction sur l'élément de liste, puis peignez la piste avec un dégradé divisé et les deux icônes de pseudo-élément à partir des mêmes variables.
.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); }
Avec cette configuration, les espaces n'ont plus besoin de leur propre couleur d'arrière-plan (le dégradé de la piste gère l'affichage). Vous pouvez donc supprimer la règle background-color sur .SwipeableList-track::before, ::after de l'étape 2 si vous utilisez cette variante à double action.
Bonnes pratiques et pièges à éviter
- À FAIRE : Utilisez l'ancrage
mandatory, pasproximity. Avecproximity, la ligne peut rester partiellement défilée, laissant l'arrière-plan de l'action à moitié visible. - À FAIRE : Définissez
overscroll-behavior-x: nonesur la piste. Sans cela, un balayage excessif peut déclencher le geste de retour en arrière du navigateur sur iOS/Android. - À FAIRE : validez à un seuil avant que l'alignement ne se stabilise (par exemple,
commitThreshold ≈ 0.2) plutôt que d'attendre que le contenu soit complètement hors de l'écran. Cela permet à l'animation de suppression de démarrer pendant le geste, ce qui est beaucoup plus réactif que d'attendre que l'alignement se produise. - À FAIRE : configurez chaque élément à partir d'une fenêtre d'affichage externe
IntersectionObserverplutôt que de configurer tous les éléments au chargement de page. Cela évite de lire les valeurs dépendantes de la mise en page (clientWidth, etc.) avant l'affichage des éléments, et permet de maintenir le nombre d'observateurs actifs proportionnel à ce que l'utilisateur peut réellement voir. - À FAIRE : Utilisez un
MutationObserverdans la liste lorsque des éléments sont ajoutés de manière dynamique (affichage initial après le chargement des données, défilement infini, server push). Sans cela, les éléments ajoutés après le chargement de page ne seront pas connectés. - NE VOUS BASEZ PAS sur
pointerdown/pointermove/pointeruppour effectuer une transformation manuelle. Vous perdrez l'inertie, le retour élastique, l'accessibilité au clavier et la gestion des animations réduites que le navigateur vous fournit sans frais. - À FAIRE : Confirmer les actions destructrices, le cas échéant. Pour "supprimer", envisagez d'afficher un toast d'annulation une fois le balayage terminé. Le geste est rapide et facile à déclencher par accident.
- À FAIRE : Assurez-vous que la barre de défilement est sélectionnable, accessible au clavier et qu'elle dispose d'un affordance de sélection visuelle.
- À FAIRE : Proposez des alternatives accessibles pour toutes les actions pertinentes déclenchées par le balayage (par exemple, un bouton visible, un menu contextuel ou un mode Édition).
Stratégies de remplacement
Toutes les fonctionnalités plus récentes utilisées ne sont pas essentielles à l'expérience ou disposent de solutions de remplacement robustes qui peuvent être utilisées de manière fiable dès maintenant.
Création de remplacement pour overscroll-behavior
Aucune solution de remplacement n'est nécessaire pour ce cas d'utilisation. Bien que overscroll-behavior présente un problème d'interopérabilité qui se manifeste sur les conteneurs sans dépassement de défilement, la piste ici est toujours défilable horizontalement (trois colonnes de largeur maximale dans un conteneur de largeur 100 %). La propriété se comporte donc de manière cohérente dans tous les navigateurs et le geste de balayage est contenu de manière fiable.
Création de remplacement pour scrollbar-width
Les barres de défilement masquées sont une amélioration visuelle, et non le mécanisme qui permet de balayer pour supprimer. Si votre cible de référence n'inclut pas scrollbar-width, la ligne défile, s'aligne, détecte la validation et se supprime correctement. L'expérience non compatible peut simplement afficher une barre de défilement horizontale. Si votre produit nécessite des barres de défilement masquées dans les anciens navigateurs dérivés de WebKit, vous pouvez ajouter une règle ::-webkit-scrollbar { display: none; } à portée limitée pour la piste de balayage.
Création de remplacement pour scroll-initial-target
Si votre cible de référence n'inclut pas scroll-initial-target, faites défiler la piste jusqu'au contenu de manière programmatique dans setupItem. Détecter avec 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.
}
L'ordre des appels est important. Le défilement programmatique DOIT s'exécuter :
- AFTER l'ajout de
.is-initializedà l'élément de liste (la classe transforme la piste en conteneur de défilement ; la définition descrollLeftsur un élément non défilable est une opération sans effet). - AVANT que l'élément
IntersectionObserverinterne de l'étape 4 ne soit associé. Sinon, le défilement programmatique initial au-delà de l'espaceur gauche sera considéré comme un balayage et déclenchera immédiatement le gestionnaire de commit.
La configuration de l'observateur de la fenêtre d'affichage externe (étape 4) est ce qui rend cette méthode fiable : setupItem s'exécute après l'affichage de l'élément, de sorte que track.clientWidth renvoie une valeur réelle plutôt que 0.
Certains navigateurs (notamment Safari) réinitialisent également la position de défilement du conteneur d'ancrage chaque fois que la piste est redimensionnée (affichage/masquage de la barre d'URL, redimensionnement de la fenêtre d'affichage, requêtes de conteneur, etc.). Utilisez un ResizeObserver sur chaque piste pour réappliquer le défilement. Limitez-le à la même vérification CSS.supports : lorsque scroll-initial-target est pris en charge, le navigateur gère lui-même la restauration du défilement lors du redimensionnement.
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.
}
Arrêtez d'observer la piste avant l'exécution de l'animation de hauteur de la ligne dans removeItem. Sinon, la modification de la hauteur déclenchera à nouveau le rappel de redimensionnement lors de la suppression. Ajoutez-le à côté du viewportObserver.unobserve(item) existant dans la branche de commit :
if (ratio < commitThreshold) {
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
observer.disconnect();
return;
}