스와이프하여 삭제하는 패턴은 모바일 애플리케이션에서 흔히 사용되지만 웹에서 깔끔하게 구현하기는 어려울 수 있습니다. 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단계: 트랙을 스냅 포인트가 3개인 가로 스냅 컨테이너로 구성하기
트랙에는 전체 너비 열이 3개 있습니다. 왼쪽 스페이서 (::before), 콘텐츠, 오른쪽 스페이서 (::after)입니다. 스페이서에 맞추면 콘텐츠가 완전히 화면 밖에 표시됩니다. 이는 스와이프가 커밋된 후의 휴지 위치입니다.
트랙 구성은 목록 항목의 .is-initialized 클래스 뒤에 제어됩니다. JS가 행을 업그레이드하기 전에는 항목이 가로 스크롤러 없이 일반 콘텐츠로 렌더링됩니다. 스와이프를 감지하는 자바스크립트가 연결되면 4단계에서 클래스가 추가됩니다. 이렇게 하면 기능이 준비되기 전에 사용자가 스와이프할 수 없습니다.
.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 규칙을 삭제할 수 있습니다.
권장사항 및 주의사항
proximity가 아닌mandatory스냅을 사용합니다.proximity를 사용하면 행이 부분적으로 스크롤된 상태로 유지되어 작업 배경이 절반만 표시될 수 있습니다.- 트랙에
overscroll-behavior-x: none를 설정해야 합니다. 이 속성이 없으면 과도한 스와이프로 인해 iOS/Android에서 브라우저의 뒤로 탐색 동작이 트리거될 수 있습니다. - 콘텐츠가 화면에서 완전히 사라질 때까지 기다리지 말고 스냅이 정착되기 전의 기준점 (예:
commitThreshold ≈ 0.2)에서 커밋해야 합니다. 이렇게 하면 동작 중에 삭제 애니메이션이 시작되므로 스냅이 완료될 때까지 기다리는 것보다 훨씬 더 반응성이 뛰어납니다. - 페이지 로드 시 모든 항목을 연결하는 대신 외부 표시 영역
IntersectionObserver에서 항목별 설정을 구동해야 합니다. 이렇게 하면 항목이 렌더링되기 전에 레이아웃 종속 값 (clientWidth등)을 읽지 않고 활성 관찰자 수를 사용자가 실제로 볼 수 있는 값에 비례하게 유지할 수 있습니다. - 항목이 동적으로 추가될 때 (데이터 로드 후 초기 렌더링, 무한 스크롤, 서버 푸시) 목록에
MutationObserver를 사용합니다. 이 속성이 없으면 페이지 로드 후 추가된 항목이 연결되지 않습니다. pointerdown/pointermove/pointerup에 의존하여 수동 변환을 유도하지 마세요. 브라우저에서 무료로 제공하는 관성, 스냅백, 키보드 접근성, 모션 감소 처리를 사용할 수 없게 됩니다.- 적절한 경우 파괴적인 작업을 확인해야 합니다. '삭제'의 경우 스와이프가 완료된 후 실행취소 토스트 메시지를 표시하는 것이 좋습니다. 이 동작은 빠르고 실수로 트리거하기 쉽습니다.
- 권장사항: 스크롤 트랙에 포커스를 맞출 수 있고 키보드로 액세스할 수 있으며 시각적 포커스 어포던스가 있는지 확인합니다.
- 권장사항: 스와이프로 트리거되는 모든 관련 작업에 액세스 가능한 대안 (예: 표시되는 버튼, 컨텍스트 메뉴, 수정 모드)을 제공합니다.
대체 전략
사용되는 모든 최신 기능은 핵심 환경이 아니거나 현재 안정적으로 사용할 수 있는 강력한 대체 기능이 있습니다.
overscroll-behavior의 대체
이 사용 사례에는 대체가 필요하지 않습니다. overscroll-behavior에는 스크롤 가능한 오버플로가 없는 컨테이너에 나타나는 상호 운용성 문제가 있지만, 여기의 트랙은 항상 가로로 스크롤할 수 있으므로 (너비가 100%인 컨테이너 내에 너비가 전체인 열 3개) 속성이 브라우저 전반에서 일관되게 작동하고 스와이프 동작이 안정적으로 포함됩니다.
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.
}
호출 순서가 중요합니다. 프로그래매틱 스크롤은 다음을 실행해야 합니다(MUST).
- AFTER
.is-initialized가 목록 항목에 추가됩니다. 클래스는 트랙을 스크롤 컨테이너로 변환하는 요소입니다. 스크롤할 수 없는 요소에scrollLeft를 설정하면 아무 작업도 수행되지 않습니다. - BEFORE 4단계의 내부
IntersectionObserver가 연결됩니다. 그렇지 않으면 왼쪽 스페이서를 지나 프로그래매틱 방식으로 스크롤하는 초기 동작이 '스와이프'로 인식되어 커밋 핸들러가 즉시 실행됩니다.
외부 표시 영역 관찰자에서 설정을 구동하면 (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;
}