Удаление с помощью жеста смахивания часто используется в мобильных приложениях, но его сложно реализовать на сайте. С помощью CSS Scroll Snap можно создать плавное, естественное взаимодействие с пролистыванием, которое подключается непосредственно к механизму прокрутки браузера. Это обеспечивает высокую производительность и физически обоснованный импульс без необходимости в сложной библиотеке жестов JavaScript.
Тот же шаблон работает для любого действия, выполняемого одним жестом (удаление, архивирование, пометка как прочитанного, откладывание). Меняется только визуальное оформление, а не механика.
Если вы хотите, чтобы при горизонтальном пролистывании открывался набор командных кнопок или боковое меню без автоматического выполнения действия, ознакомьтесь с разделом Пролистывание для показа.
Рекомендации по внедрению
У компонента два уровня: список (<ul>) и элементы в нем. Каждый элемент состоит из внешнего контейнера <li>, внутренней дорожки прокрутки (контейнера прокрутки с точками привязки) и элемента контента (видимой строки). Элементы интерфейса, связанные с действием (значок корзины, ярлык архива и т. д.), находятся по обе стороны от контента. Список владеет общей проводкой (ленивая настройка элементов, выбор вновь добавленных элементов); каждый элемент владеет собственным обнаружением свайпа.
Шаг 1. Разметьте список с помощью Track и 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>
Шаг 2. Настройте дорожку как горизонтальный контейнер с тремя точками привязки
Дорожка состоит из трех столбцов полной ширины: левого разделителя (::before), контента и правого разделителя (::after). Привязка к разделителю означает, что контент полностью находится за пределами экрана. Это положение покоя после завершения пролистывания.
Конфигурация трека скрыта за классом .is-initialized в элементе списка. До того как JS обновит строку, элемент отображается как обычный контент без горизонтальной прокрутки. Класс добавляется на шаге 4 после подключения кода JavaScript, который обнаруживает пролистывания. Это гарантирует, что пользователь не сможет провести пальцем по экрану до того, как функция будет готова.
.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;
}
Шаг 3. Добавьте значки действий в элемент списка
Цвет действия используется на разделителях (шаг 2). Значок действия находится на самом элементе списка, привязанном к левому и правому краям строки.
Расположение, размер и движение, описанные ниже, – это рекомендации, а не требования. Настройте размер значка, отступы по краям, масштаб всплывающего окна, длительность перехода и даже выбор псевдоэлементов по сравнению с реальными узлами DOM в соответствии с вашим дизайном. Единственное техническое требование – значок должен находиться за контентом (чтобы контент мог закрывать его до пролистывания) и внутри элемента списка (чтобы он не прокручивался вместе с разделителем). Все остальное – дело вкуса.
/* 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;
}
Шаг 4. Обнаружение жеста подтверждения с помощью IntersectionObserver
Используйте IntersectionObserver, укорененный в треке, наблюдая за контентом. Когда пользователь пролистывает контент, коэффициент пересечения контента с дорожкой падает. Мы используем два пороговых значения:
activateThreshold– высокий коэффициент (например, 0,8). Если видимая часть контента меньше этого значения, пользователь прошел точку визуальной активации. Переключите значок.commitThreshold: низкое соотношение (например, 0,2). Если видимая часть становится меньше этого значения, пользователь совершил действие. Запускайте анимацию удаления сразу, не дожидаясь, пока жест привязки полностью завершится. Сворачивающаяся строка сливается с продолжающимся движением пальца пользователя, что кажется более отзывчивым, чем ожидание приземления привязки перед реакцией.
Здесь решаются ещё две проблемы:
- Отложенная настройка для каждого элемента. Один внешний экземпляр
IntersectionObserver, привязанный к области просмотра, управляет настройкой и запуском/остановкой внутренних наблюдателей за свайпами. Элементы подключаются только при первом прокручивании в поле зрения, а у элементов, которые прокручиваются за пределы экрана, наблюдатель за свайпом приостанавливается. Это позволяет ограничить количество активных наблюдателей и избежать считывания значений, зависящих от макета (например,clientWidth), до того, как элемент будет отрисован. - Динамические элементы. Реальные списки со временем увеличиваются (первоначальная отрисовка, бесконечная прокрутка, push-уведомления от сервера).
MutationObserverв<ul>регистрирует все новые элементы во внешнем наблюдателе.
// 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);
Шаг 5. Используйте действие и ярлык, которые подходят для вашего случая
Для вариантов, отличных от удаления, меняются только визуальные элементы и тело обработчика фиксации:
- Архивировать – зеленый или синий фон, значок архива, перемещение элемента в список архива вместо удаления.
- Отметить как прочитанное. Фон становится приглушенным, появляется значок галочки, статус элемента обновляется, и он отображается заново (или просто удаляется класс
.unread). - Отложить – синий фон, значок часов, скрыть до выбранного времени.
Механика прокрутки, привязки и наблюдения не изменилась.
Разные действия для разных направлений пролистывания.
В одной строке можно настроить два разных действия: одно для пролистывания влево, а другое – для пролистывания вправо (например, "архивировать" справа, "удалить" слева), как это сделано во многих почтовых приложениях. Механика прокрутки, привязки и обнаружения фиксации не меняется. Изменяется только то, что происходит во время фиксации.
На шаге 4 ветвь фиксации всегда вызывает removeItem(...). Чтобы поддерживать два действия, выберите обработчик на основе direction:
if (ratio < commitThreshold) {
const handler = direction === 'left' ? archiveItem : removeItem;
handler(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
Примечание о названии. removeItem назван в честь деструктивного случая, но функция, которую он запускает (свернуть высоту строки, сдвинуть содержимое за пределы экрана, а затем удалить узел), на самом деле является общей процедурой "эта строка готова, анимируйте ее". Это также работает для архивирования, пометки как прочитанного или откладывания – строка в любом случае исчезает из этого списка. Если обработчики не удаляют элементы, а, например, перемещают их, переименуйте параметр, чтобы он звучал нейтрально, например dismissItem.
Чтобы визуально различать эти два действия, поднимите цвет и значок для каждого направления на элемент списка, а затем закрасьте дорожку разделенным градиентом и двумя значками псевдоэлементов из тех же переменных.
.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); }
При такой настройке разделителям больше не нужен собственный цвет фона (градиент дорожки обрабатывает раскрытие), поэтому вы можете удалить правило background-color для .SwipeableList-track::before, ::after из шага 2, если используете этот вариант двойного действия.
Рекомендации и распространенные ошибки
- РЕКОМЕНДУЕТСЯ использовать
mandatorysnap, а неproximity. Вproximityстрока может быть прокручена частично, оставляя фон действия полувидимым. - РЕКОМЕНДУЕТСЯ задавать для дорожки значение
overscroll-behavior-x: none. Без этого свойства слишком сильный свайп может активировать жест обратной навигации в браузере на iOS или Android. - РЕКОМЕНДУЕТСЯ фиксировать переход на пороговом значении до завершения привязки (например,
commitThreshold ≈ 0.2), а не ждать, пока контент полностью исчезнет с экрана. Это позволяет запустить анимацию удаления во время жеста, что кажется значительно более отзывчивым, чем ожидание привязки. - РЕКОМЕНДУЕТСЯ настраивать каждый элемент из внешней области просмотра
IntersectionObserver, а не подключать все элементы при загрузке страницы. Это позволяет избежать считывания значений, зависящих от макета (clientWidthи т. д.), до того, как элементы будут отрисованы, и сохранить количество активных наблюдателей пропорциональным тому, что пользователь может видеть на самом деле. - ИСПОЛЬЗУЙТЕ
MutationObserverв списке, когда элементы добавляются динамически (первоначальная отрисовка после загрузки данных, бесконечная прокрутка, push-уведомления от сервера). В противном случае элементы, добавленные после загрузки страницы, не будут связаны. - НЕ полагайтесь на
pointerdown/pointermove/pointerupдля ручного преобразования. Вы потеряете инерцию, возврат, доступность клавиатуры и обработку уменьшенного движения, которые браузер предоставляет вам бесплатно. - РЕКОМЕНДУЕТСЯ запрашивать подтверждение действий, которые могут привести к потере данных. Если вы используете жест для удаления, рекомендуем показывать всплывающее сообщение с возможностью отмены действия, поскольку жест легко выполнить случайно.
- РЕКОМЕНДУЕТСЯ обеспечить возможность фокусировки на полосе прокрутки и управления ею с клавиатуры, а также визуальное обозначение фокуса.
- РЕКОМЕНДУЕТСЯ предоставлять доступные альтернативы для любых действий, запускаемых жестом пролистывания (например, видимую кнопку, контекстное меню или режим редактирования).
Резервные стратегии
Все новые функции, которые используются, либо не являются основными, либо имеют надежные резервные варианты, которые можно использовать уже сейчас.
Резервное объявление для overscroll-behavior
В этом случае резервное объявление не требуется. Хотя у overscroll-behavior есть проблема совместимости, которая проявляется в контейнерах без прокручиваемого переполнения, дорожка здесь всегда прокручивается по горизонтали (три столбца полной ширины внутри контейнера 100%-ной ширины), поэтому свойство ведет себя одинаково во всех браузерах, а жест пролистывания надежно сдерживается.
Резервное объявление для scrollbar-width
Скрытые полосы прокрутки – это визуальное улучшение, а не механизм, который позволяет удалять элементы с помощью жеста пролистывания. Если целевой Baseline не включает scrollbar-width, строка все равно будет прокручиваться, привязываться, обнаруживать фиксацию и удаляться правильно. В неподдерживаемом режиме может просто отображаться горизонтальная полоса прокрутки. Если в вашем продукте требуется скрыть полосы прокрутки в старых браузерах на основе WebKit, вы можете добавить правило ::-webkit-scrollbar { display: none; } для дорожки прокрутки.
Резервное объявление для scroll-initial-target
Если целевая версия Baseline не включает scroll-initial-target, прокрутите дорожку до контента программно внутри setupItem. Обнаружение с помощью 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.
}
Порядок вызовов имеет значение. Программная прокрутка должна выполняться:
- AFTER
.is-initializedдобавляется к элементу списка (класс превращает дорожку в контейнер прокрутки; установкаscrollLeftдля непрокручиваемого элемента не дает результата). - ДО прикрепления внутреннего элемента
IntersectionObserverиз шага 4. В противном случае начальная программная прокрутка за левый разделитель будет воспринята как жест пролистывания и немедленно запустит обработчик фиксации.
Настройка, выполняемая с помощью наблюдателя за внешней областью просмотра (шаг 4), обеспечивает надежность этого процесса: setupItem выполняется после отрисовки элемента, поэтому track.clientWidth возвращает реальное значение, а не 0.
Некоторые браузеры (в частности, Safari) также сбрасывают позицию прокрутки контейнера привязки при каждом изменении размера дорожки (показ/скрытие панели URL, изменение размера области просмотра, запросы контейнера и т. д.). Чтобы снова применить прокрутку, используйте ResizeObserver на каждой дорожке. Используйте ту же проверку CSS.supports. Если scroll-initial-target поддерживается, браузер сам восстанавливает прокрутку при изменении размера.
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.
}
Прекратите наблюдение за дорожкой до того, как в removeItem запустится анимация высоты строки. В противном случае изменение высоты снова вызовет обратный вызов изменения размера во время удаления. Добавьте в ветку фиксации следующий код рядом с существующим viewportObserver.unobserve(item):
if (ratio < commitThreshold) {
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
observer.disconnect();
return;
}