滑动即可删除

滑动移除模式在移动应用中很常见,但在 Web 上很难干净利落地实现。通过使用 CSS 滚动贴靠,您可以创建流畅、自然的滑动互动,直接与浏览器的滚动引擎对接。这样可以确保高性能和基于物理的动量,而无需复杂的 JavaScript 手势库。

此模式适用于任何单操作滑动(移除、归档、标记为已读、延后)。动作视觉效果会发生变化,但机制不会。

如果您希望通过水平滑动来打开一组操作按钮或侧边菜单,而不在滑动期间自动执行操作,请参阅滑动以显示。

如何实现

该组件有两层:列表 (<ul>) 及其中的项。每个项都由一个外层 <li>、一个内层滚动轨道(带有吸附点的滚动容器)和一个内容元素(可见行)组成。操作显示的界面(垃圾箱图标、归档标签等)位于内容的两侧。列表拥有共享的连接(延迟项设置、获取新添加的项);每个项都有自己的滑动检测。

第 1 步:使用曲目和内容标记列表

<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)。
  • 动态项:实际列表会随着时间的推移而增长(初始渲染、无限滚动、服务器推送)。<ul> 上的 MutationObserver 会向外部观察器注册所有新添加的项。
// 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(轨道渐变会处理显示效果),因此如果您使用的是这种双操作变体,则可以从第 2 步中删除 .SwipeableList-track::before, ::after 上的 background-color 规则。

最佳实践和陷阱

  • 务必使用 mandatory snap,而不是 proximity。使用 proximity 时,行可以处于部分滚动状态,使操作背景半可见。
  • 务必在轨道上设置 overscroll-behavior-x: none。如果没有它,过度滑动可能会在 iOS/Android 上触发浏览器的返回导航手势。
  • 务必在贴靠完成之前(例如 commitThreshold ≈ 0.2)达到阈值时提交,而不是等待内容完全移出屏幕。这样,移除动画就可以在手势期间开始,这比等待贴靠完成要灵敏得多。
  • 务必从外部视口 IntersectionObserver 驱动每个项的设置,而不是在网页加载时连接每个项。这样可以避免在渲染项之前读取依赖于布局的值(clientWidth 等),并使活跃观察器数量与用户实际可以看到的内容成正比。
  • 务必在动态添加列表项(数据加载后的初始渲染、无限滚动、服务器推送)时,在列表上使用 MutationObserver。如果没有它,网页加载后附加的内容将无法连接。
  • 请勿依赖 pointerdown/pointermove/pointerup 来驱动手动转换。您将失去浏览器免费提供的惯性、回弹、键盘无障碍功能和减少动画处理。
  • 务必在适当的时候确认破坏性操作。对于“移除”,请考虑在滑动完成后显示撤消消息框;该手势很快,很容易意外触发。
  • 务必确保滚动条轨道可聚焦、可通过键盘访问,并提供视觉焦点提示。
  • 务必为滑动触发的所有相关操作提供无障碍替代方案(例如,可见按钮、上下文菜单或编辑模式)。

后备策略

所使用的所有较新功能要么不是核心体验,要么具有现在可以可靠使用的强大后备功能。

overscroll-behavior 的后备

此用例不需要后备。虽然 overscroll-behavior 存在一个互操作性问题,该问题会在没有可滚动溢出的容器上显现,但此处的轨道始终可水平滚动(100% 宽度容器内的三个全宽列),因此该属性在不同浏览器中的行为一致,并且滑动滑动手势可以可靠地包含在内。

scrollbar-width 的后备

隐藏滚动条是一种视觉增强功能,而不是实现滑动移除功能的机制。如果您的基准目标不包含 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.
}

调用顺序很重要。程序化滚动必须:

  1. AFTER:.is-initialized 会添加到列表项中(该类会将轨道转换为滚动容器;在不可滚动元素上设置 scrollLeft 不会产生任何效果)。
  2. 在附加第 4 步中的内部 IntersectionObserver 之前。否则,系统会将初始程序化滚动(越过左侧空白区域)视为“滑动”,并立即触发提交处理程序。

从外部视口观察器(第 4 步)驱动设置可确保可靠性:setupItem 会在项呈现后运行,因此 track.clientWidth 会返回实际值,而不是 0。

每当轨道大小发生变化(网址栏显示/隐藏、视口大小调整、容器查询等)时,某些浏览器(尤其是 Safari)还会重置贴靠容器的滚动位置。在每个轨道上使用 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;
}