滑動移除模式在行動應用程式中很常見,但在網頁上可能難以乾淨俐落地實作。使用 CSS 捲動貼齊功能,即可建立流暢、原生感的滑動互動,直接連結至瀏覽器的捲動引擎。這樣一來,您不必使用複雜的 JavaScript 手勢程式庫,就能確保高效能和物理動量。
任何單一動作滑動 (移除、封存、標示為已讀、延後) 都適用相同模式。動作視覺效果會改變,但機制不變。
如果您希望水平滑動時,開啟一組動作按鈕或側邊選單,而「不」在滑動時自動執行動作,請參閱「滑動顯示」。
導入方式
這個元件有兩個層級:清單 (<ul>) 和其中的項目。每個項目都由外部 <li>、內部捲動軌道 (具有貼齊點的捲動容器) 和內容元素 (可見列) 組成。動作顯示的 UI (垃圾桶圖示、封存標籤等) 會顯示在內容的任一側。清單擁有共用線路 (延遲項目設定、挑選新加入的項目);每個項目都有自己的滑動偵測。
步驟 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貼齊,而非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 的備用廣告
如果基準目標不含 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不會產生任何作用)。 - 在附加步驟 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;
}