Mẫu vuốt để xoá thường thấy trong các ứng dụng di động nhưng có thể khó triển khai một cách rõ ràng trên web. Bằng cách sử dụng CSS Scroll Snap, bạn có thể tạo một tương tác vuốt mượt mà, mang lại cảm giác tự nhiên, kết nối trực tiếp với công cụ cuộn của trình duyệt. Điều này đảm bảo hiệu suất cao và động lượng dựa trên vật lý mà không cần thư viện cử chỉ JavaScript phức tạp.
Mẫu tương tự cũng áp dụng cho mọi thao tác vuốt một lần (xoá, lưu trữ, đánh dấu là đã đọc, tạm ẩn). Hình ảnh của hành động sẽ thay đổi, nhưng cơ chế thì không.
Nếu bạn muốn vuốt theo chiều ngang để mở nhanh một nhóm nút hành động hoặc trình đơn bên mà không tự động thực hiện hành động trong khi vuốt, hãy xem phần Vuốt để hiển thị.
Cách triển khai
Thành phần này có hai lớp: một danh sách (<ul>) và các mục bên trong danh sách đó. Mỗi mục được cấu trúc dưới dạng một <li> bên ngoài, một track cuộn bên trong (vùng chứa cuộn có các điểm bám) và một phần tử content (hàng hiển thị). Giao diện người dùng được hiển thị của hành động (biểu tượng thùng rác, nhãn lưu trữ, v.v.) nằm ở hai bên của nội dung. Danh sách sở hữu hệ thống dây điện dùng chung (thiết lập mục lười, chọn các mục mới được thêm); mỗi mục sở hữu tính năng phát hiện thao tác vuốt riêng.
Bước 1: Đánh dấu danh sách bằng bản nhạc và nội dung
<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>
Bước 2: Định cấu hình dải làm vùng chứa chụp nhanh theo chiều ngang có 3 điểm chụp nhanh
Đường kéo có 3 cột có chiều rộng đầy đủ: khoảng trống bên trái (::before), nội dung và khoảng trống bên phải (::after). Việc bám vào khoảng trống có nghĩa là nội dung hoàn toàn nằm ngoài màn hình, tức là vị trí nghỉ sau khi vuốt.
Cấu hình theo dõi được kiểm soát bằng một lớp .is-initialized trên mục danh sách. Trước khi JS nâng cấp hàng, mục này chỉ hiển thị dưới dạng nội dung thuần tuý mà không có thanh cuộn ngang. Lớp này được thêm vào Bước 4 sau khi JavaScript phát hiện thao tác vuốt được kết nối. Điều này đảm bảo người dùng không thể vuốt trước khi chức năng này sẵn sàng.
.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;
}
Bước 3: Thêm biểu tượng hành động vào mục danh sách
Màu hành động nằm trên các dải phân cách (Bước 2). Biểu tượng hành động nằm trên chính mục danh sách, được neo vào cạnh trái và phải của hàng.
Vị trí, kích thước và chuyển động bên dưới là gợi ý ban đầu, không phải là yêu cầu bắt buộc. Điều chỉnh kích thước biểu tượng, phần lề, tỷ lệ ngưỡng bật lên, thời lượng chuyển đổi và thậm chí là lựa chọn phần tử giả so với nút DOM thực để phù hợp với thiết kế của bạn. Yêu cầu cơ học duy nhất là biểu tượng nằm phía sau nội dung (để nội dung có thể che biểu tượng trước khi vuốt) và bên trong mục danh sách (để biểu tượng không cuộn cùng với dải phân cách). Mọi thứ khác đều là sở thích.
/* 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;
}
Bước 4: Phát hiện cử chỉ cam kết bằng IntersectionObserver
Sử dụng IntersectionObserver bắt nguồn từ bản nhạc, quan sát nội dung. Khi người dùng vuốt, tỷ lệ giao nhau của nội dung với dải sẽ giảm; chúng ta sử dụng hai ngưỡng:
activateThreshold: tỷ lệ cao (ví dụ: 0,8). Khi phần nội dung hiển thị giảm xuống dưới mức này, người dùng đã vượt qua điểm kích hoạt hình ảnh. Bật/tắt khả năng hiển thị biểu tượng bật lên.commitThreshold: tỷ lệ thấp (ví dụ: 0,2). Khi phần hiển thị giảm xuống dưới mức này, người dùng đã cam kết. Bắt đầu ảnh động xoá ngay lập tức mà không cần đợi cử chỉ chụp nhanh hoàn tất. Hàng thu gọn sẽ hoà vào đà vuốt liên tục của người dùng, mang lại cảm giác phản hồi nhanh hơn so với việc chờ thao tác chụp nhanh trước khi phản ứng.
Hai mối lo ngại khác được xử lý tại đây:
- Thiết lập từng mục theo kiểu tải từng phần: một
IntersectionObserverbên ngoài duy nhất bắt nguồn từ khung nhìn sẽ điều khiển quá trình thiết lập và bắt đầu/dừng trình quan sát thao tác vuốt bên trong. Các mục chỉ được kết nối trong lần đầu tiên chúng cuộn vào khung hiển thị và các mục cuộn ra khỏi màn hình sẽ bị tạm dừng trình quan sát thao tác vuốt. Việc này giúp giới hạn số lượng trình quan sát đang hoạt động và tránh đọc các giá trị phụ thuộc vào bố cục (nhưclientWidth) trước khi mục được kết xuất. - Mục động: danh sách thực tế tăng dần theo thời gian (kết xuất ban đầu, cuộn vô hạn, công nghệ đẩy của máy chủ).
MutationObservertrên<ul>đăng ký mọi mục mới được thêm vào trình quan sát bên ngoài.
// 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);
Bước 5: Sử dụng hành động và nhãn phù hợp với trường hợp sử dụng của bạn
Đối với các biến thể không phải là xoá, chỉ có hình ảnh và phần nội dung của trình xử lý thay đổi:
- Lưu trữ: nền xanh lục/xanh dương, biểu tượng lưu trữ, di chuyển mục vào danh sách lưu trữ thay vì xoá mục đó.
- Đánh dấu là đã đọc: nền mờ, biểu tượng dấu kiểm, cập nhật trạng thái mục và kết xuất lại (hoặc chỉ xoá lớp
.unread). - Tạm ẩn: nền màu xanh dương, biểu tượng đồng hồ, ẩn cho đến thời điểm đã chọn.
Cơ chế cuộn/bắt dính/quan sát không thay đổi.
Các thao tác khác nhau cho mỗi hướng vuốt
Một hàng cũng có thể hiển thị hai hành động khác nhau: một hành động khi vuốt sang trái và một hành động khi vuốt sang phải (ví dụ: "lưu trữ" khi vuốt sang phải, "xoá" khi vuốt sang trái), giống như cách nhiều ứng dụng thư gốc thực hiện. Cơ chế cuộn, cố định vị trí và phát hiện cam kết không thay đổi gì cả — điều duy nhất thay đổi là những gì xảy ra tại thời điểm cam kết.
Trong Bước 4, nhánh cam kết luôn gọi removeItem(...). Để hỗ trợ hai hành động, hãy chọn trình xử lý dựa trên direction:
if (ratio < commitThreshold) {
const handler = direction === 'left' ? archiveItem : removeItem;
handler(item, content, direction, entry);
viewportObserver.unobserve(item);
observer.disconnect();
return;
}
Lưu ý về cách đặt tên: removeItem được đặt tên cho trường hợp huỷ bỏ, nhưng hàm mà nó chạy (thu gọn chiều cao của hàng, trượt nội dung ra khỏi màn hình, sau đó thả nút) thực sự là một quy trình chung "hàng này đã hoàn tất, hãy tạo ảnh động cho hàng đó". Thao tác này cũng hoạt động tương tự đối với thao tác lưu trữ, đánh dấu là đã đọc hoặc tạm ẩn — hàng sẽ biến mất khỏi danh sách này theo cách này hay cách khác. Nếu trình xử lý của bạn không thực sự xoá bất kỳ nội dung nào (ví dụ: cả hai đều di chuyển mục đến nơi khác), hãy đổi tên thành một tên trung lập như dismissItem để mã đọc chính xác.
Để phân biệt hai hành động một cách trực quan, hãy đưa màu và biểu tượng cho từng hướng lên mục danh sách, sau đó vẽ dải màu bằng dải chuyển màu phân tách và hai biểu tượng phần tử giả từ cùng một biến.
.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); }
Với cách thiết lập này, các dải phân cách không cần màu nền riêng nữa (màu chuyển tiếp của dải phân cách sẽ xử lý việc hiển thị), vì vậy, bạn có thể bỏ quy tắc background-color trên .SwipeableList-track::before, ::after từ Bước 2 nếu đang sử dụng biến thể thao tác kép này.
Các phương pháp hay nhất và những điều cần tránh
- NÊN dùng ảnh chụp nhanh
mandatory, chứ không phảiproximity. Vớiproximity, hàng có thể dừng lại khi được cuộn một phần, khiến nền hành động hiển thị một nửa. - NÊN đặt
overscroll-behavior-x: nonetrên bản nhạc. Nếu không có thuộc tính này, thao tác vuốt quá mức có thể kích hoạt cử chỉ điều hướng quay lại của trình duyệt trên iOS/Android. - NÊN cam kết ở một ngưỡng trước khi điểm đính được xác định (ví dụ:
commitThreshold ≈ 0.2) thay vì chờ nội dung hoàn toàn ra khỏi màn hình. Điều này cho phép ảnh động xoá bắt đầu trong khi thực hiện cử chỉ, mang lại cảm giác phản hồi nhanh hơn đáng kể so với việc chờ cử chỉ vuốt nhanh kết thúc. - NÊN thiết lập từng mục từ khung nhìn bên ngoài
IntersectionObserverthay vì kết nối mọi mục khi tải trang. Điều này giúp tránh đọc các giá trị phụ thuộc vào bố cục (clientWidth, v.v.) trước khi các mục được hiển thị và giữ cho số lượng trình quan sát đang hoạt động tương ứng với những gì người dùng thực sự có thể thấy. - NÊN sử dụng
MutationObservertrong danh sách khi các mục được thêm một cách linh động (kết xuất ban đầu sau khi tải dữ liệu, cuộn vô hạn, công nghệ đẩy của máy chủ). Nếu không có, các mục được thêm vào sau khi tải trang sẽ không được kết nối. - KHÔNG dựa vào
pointerdown/pointermove/pointerupđể thực hiện phép biến đổi thủ công. Bạn sẽ mất đi động lực, khả năng quay lại, khả năng hỗ trợ tiếp cận bằng bàn phím và khả năng xử lý chuyển động giảm mà trình duyệt cung cấp miễn phí cho bạn. - NÊN xác nhận các hành động có tính huỷ diệt khi thích hợp. Đối với thao tác "xoá", hãy cân nhắc việc hiển thị thông báo ngắn về việc huỷ sau khi hoàn tất thao tác vuốt; cử chỉ này nhanh và dễ vô tình kích hoạt.
- NÊN đảm bảo thanh cuộn có thể lấy tiêu điểm, có thể truy cập bằng bàn phím và có khả năng lấy tiêu điểm trực quan.
- NÊN cung cấp các lựa chọn thay thế dễ tiếp cận cho mọi hành động liên quan được kích hoạt bằng thao tác vuốt (ví dụ: nút hiển thị, trình đơn theo bối cảnh hoặc chế độ chỉnh sửa).
Chiến lược dự phòng
Tất cả các tính năng mới hơn được sử dụng đều không phải là tính năng cốt lõi của trải nghiệm hoặc có các tính năng dự phòng mạnh mẽ có thể được sử dụng một cách đáng tin cậy ngay bây giờ.
Dự phòng cho overscroll-behavior
Không cần dự phòng cho trường hợp sử dụng này. Mặc dù overscroll-behavior có vấn đề về khả năng tương tác thể hiện trên các vùng chứa không có mục tràn có thể cuộn, nhưng dải ở đây luôn có thể cuộn theo chiều ngang (ba cột có chiều rộng đầy đủ bên trong vùng chứa có chiều rộng 100%), vì vậy, thuộc tính này hoạt động nhất quán trên các trình duyệt và cử chỉ vuốt được chứa một cách đáng tin cậy.
Dự phòng cho scrollbar-width
Thanh cuộn ẩn là một tính năng nâng cao về hình ảnh, chứ không phải là cơ chế giúp tính năng vuốt để xoá hoạt động. Nếu mục tiêu Baseline của bạn không bao gồm scrollbar-width, thì hàng vẫn cuộn, cố định vị trí, phát hiện thao tác xác nhận và xoá chính xác; trải nghiệm không được hỗ trợ có thể chỉ hiển thị thanh cuộn ngang. Nếu sản phẩm của bạn yêu cầu thanh cuộn ẩn trong các trình duyệt cũ dựa trên WebKit, bạn có thể thêm quy tắc ::-webkit-scrollbar { display: none; } có phạm vi hẹp cho dải vuốt.
Dự phòng cho scroll-initial-target
Nếu mục tiêu Baseline không bao gồm scroll-initial-target, hãy di chuyển bản nhạc đến nội dung theo phương thức lập trình bên trong setupItem. Phát hiện bằng 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.
}
Thứ tự gọi là yếu tố quan trọng. Thao tác cuộn có lập trình PHẢI chạy:
- AFTER
.is-initializedđược thêm vào mục danh sách (lớp này biến bản nhạc thành vùng chứa cuộn; việc đặtscrollLefttrên một phần tử không thể cuộn sẽ không có tác dụng). - TRƯỚC KHI gắn
IntersectionObserverbên trong ở Bước 4. Nếu không, thao tác cuộn có lập trình ban đầu qua dải phân cách bên trái sẽ được coi là "thao tác vuốt" và ngay lập tức kích hoạt trình xử lý cam kết.
Việc thiết lập từ trình quan sát khung nhìn bên ngoài (Bước 4) là yếu tố giúp đảm bảo độ tin cậy: setupItem chạy sau khi mục được kết xuất, vì vậy, track.clientWidth trả về một giá trị thực thay vì 0.
Một số trình duyệt (đặc biệt là Safari) cũng đặt lại vị trí cuộn của vùng chứa chụp nhanh bất cứ khi nào bản nhạc đổi kích thước (hiện/ẩn thanh URL, đổi kích thước khung nhìn, truy vấn vùng chứa, v.v.). Sử dụng ResizeObserver trên mỗi bản nhạc để áp dụng lại thao tác cuộn. Hãy kiểm tra bằng cùng một phương thức CSS.supports – khi scroll-initial-target được hỗ trợ, trình duyệt sẽ tự xử lý việc khôi phục thao tác cuộn khi đổi kích thước.
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.
}
Huỷ quan sát bản nhạc trước khi ảnh động chiều cao của hàng chạy trong removeItem, nếu không, thay đổi chiều cao sẽ kích hoạt lại lệnh gọi lại đổi kích thước trong quá trình xoá. Thêm nội dung này cùng với viewportObserver.unobserve(item) hiện có trong nhánh cam kết:
if (ratio < commitThreshold) {
removeItem(item, content, direction, entry);
viewportObserver.unobserve(item);
if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
observer.disconnect();
return;
}