개요
스택 드릴다운은 모바일 앱에서 흔히 볼 수 있는 계층적 탐색 패턴으로, 링크를 활성화하면 이전 화면 위에 새로운 전체 화면 뷰가 표시됩니다. 뷰의 콘텐츠는 애플리케이션에서 정의합니다(예: 설정 하위 페이지, 피드 내 대화목록, 파일 브라우저 내 폴더, 갤러리 내 세부정보 페이지 등). 사용자는 현재 뷰를 화면 밖 오른쪽으로 스와이프하거나 뒤로 버튼을 탭하여 돌아갑니다. 브라우저 기록이 동기화된 상태로 유지되므로 OS 수준의 뒤로 동작, 딥 링크, 앞으로/뒤로 탐색이 모두 일관되게 작동합니다.
이 가이드에서는 스택을 다음과 같이 구현합니다.
- 각 뷰가 정확히 하나의 스냅 중지인 가로 CSS 스크롤 스냅 컨테이너입니다. 드릴다운하면 새 뷰가 추가되고 부드럽게 스크롤됩니다. 스와이프백 동작은 브라우저에서 기본적으로 처리되므로 포인터 이벤트 JavaScript 없이도 모멘텀, 속도, 중단 추적을 무료로 제공합니다.
- 스냅 타겟이 변경될 때 실행되는 스택의
scrollsnapchange이벤트 리스너입니다. 이는 '활성 뷰 변경'에 대한 단일 정보 소스로 사용되므로 스와이프, 클릭, 프로그래매틱 스크롤,popstate경로가 모두 하나의 콜백으로 수렴되어inert를 업데이트하고, 포커스를 복원하고, 사용자가 스와이프한 뷰를 정리하고, 브라우저 방문 기록을 조정합니다. pushState/popstate통합을 통해 모든 드릴다운에서 기록 항목을 추가하고, OS 수준 뒤로 동작이 작동하며, 딥 링크가 올바른 뷰로 직접 열립니다.- 각 뷰의 (선택사항) 스크롤 기반
view()애니메이션은 스와이프 동작에 직접 연결된 패럴랙스 + 어둡게 + 그림자 효과를 생성하므로 표시되는 모션은 트윈이 아닌 사용자의 손가락으로 구동됩니다.
이 접근 방식은 JavaScript 기반 transform 애니메이션보다 선호됩니다. 스냅 메커니즘을 통해 사용자가 패널 위치를 직접 동작으로 제어할 수 있고 (트윈이 아닌 손가락으로 제어) 사용자가 기본 모바일 앱에서 기대하는 상호작용 패턴과 일치하기 때문입니다.
구현
1. 마크업
정적 HTML은 빈 스택 컨테이너일 뿐입니다. 뷰는 JavaScript로 빌드되고 사용자가 탐색할 때 추가됩니다.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
각 뷰는 .Stack (스냅 타겟)의 .Stack-view 직접 하위 요소이며, 내부에 .Stack-viewContent 래퍼가 있습니다 (뷰 콘텐츠가 있고 패럴랙스 변환이 적용되는 위치 - 2단계 참고). 스택에는 언제든지 활성 기록 항목당 하나의 뷰가 드릴다운 순서로 왼쪽에서 오른쪽으로 표시됩니다. 사용자가 루트에서 두 수준을 드릴다운하면 렌더링된 DOM은 다음과 같습니다.
<div class="Stack">
<!-- Root view. Has whatever content makes sense as the entry point
of this section of the app. No back button — there is nothing
behind the root in the stack. -->
<div class="Stack-view" inert>
<div class="Stack-viewContent">
<!-- Root content; includes <a href> links that drill in. -->
</div>
</div>
<!-- First-level drill-down view. The user got here by activating a
link in the root view. -->
<div class="Stack-view" inert>
<div class="Stack-viewContent">
<header>
<!-- DO include a back button. The swipe gesture only works on
touch — keyboard and pointer users need an explicit control. -->
<button class="back" aria-label="Back"></button>
<!-- Title / breadcrumb / etc. -->
</header>
<main>
<!-- View content; may include further drill-down <a href> links. -->
</main>
</div>
</div>
<!-- Second-level drill-down view. Currently visible — no `inert`
attribute. Same shape as the first-level view. -->
<div class="Stack-view">
<div class="Stack-viewContent">
<header>
<button class="back" aria-label="Back"></button>
<!-- ... -->
</header>
<main><!-- ... --></main>
</div>
</div>
</div>
참고:
- 현재 표시되는 뷰를 제외한 모든 뷰에는
inert속성이 있습니다. 이는 7단계의scrollsnapchange핸들러에 의해 자동으로 적용/삭제됩니다. 뷰 빌더에서 설정하지 마세요. - 사용자가 뒤로 스와이프한 뷰는 DOM에서 삭제되므로 (7단계) 스택은 하위 요소
currentDepth + 1개를 초과하여 증가하지 않습니다. 앞으로 탐색이 반환되면 캐시된 URL 경로에서 요청 시 다시 빌드됩니다.
2. 스타일
스택 스크롤러
스택은 각 하위 뷰가 컨테이너의 너비와 정확히 일치하는 가로 그리드이며, CSS scroll-snap은 스냅당 하나의 뷰를 적용합니다. 이렇게 하면 스와이프하여 뒤로 동작이 기본 동작처럼 느껴집니다.
.Stack {
/* Use dvh so the height tracks the dynamic viewport on mobile, where
the address bar can show/hide. svh would clip during the address bar
animation; vh leaks under it. */
height: 100dvh;
/* Lay views out left-to-right, each one full-width, so horizontal
scrolling moves between them one at a time. */
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
grid-template-rows: 100%;
overflow-x: auto;
/* `mandatory` guarantees the stack always settles fully on a view —
never half-way between two. */
scroll-snap-type: x mandatory;
/* Prevent the swipe-back gesture from chaining into the browser's
own history-back gesture (iOS, some Android) or the page's vertical
scroll. The user is navigating the stack, not the page. */
overscroll-behavior-x: none;
}
/* Hide the visual scrollbar — the snap and the parallax are the
affordances; a horizontal scrollbar would look out of place. */
.Stack::-webkit-scrollbar {
display: none;
}
/* MANDATORY: Opt into smooth programmatic scrolling via CSS, gated on
prefers-reduced-motion. JS code calls scrollTo/scrollBy with
behavior: 'auto' which defers to this rule, so the OS-level reduced-
motion preference automatically downgrades to instant scrolling
without any per-call JS branching. */
@media (prefers-reduced-motion: no-preference) {
.Stack {
scroll-behavior: smooth;
}
}
.Stack-view {
scroll-snap-align: start;
/* `always` prevents the user from blowing through more than one view
per gesture, so depth changes always happen one step at a time. */
scroll-snap-stop: always;
}
/* MANDATORY: A separate inner element is required for the parallax
transform below. Applying transforms directly to the snap target
(.Stack-view) would feed back into the scroll container's snap
geometry and the scroller would jump mid-gesture. */
.Stack-viewContent {
width: 100%;
height: 100%;
background-color: #fff;
/* Each view scrolls its own content vertically, independent of the
stack's horizontal scroll. */
overflow-y: auto;
}
'스택' 효과 (패럴랙스/어둡게/그림자)
스크롤 기반 view(inline) 애니메이션은 스택 스크롤러를 통해 각 뷰의 진행 상황을 추적하고 종료되는 뷰에 패럴랙스 + 어둡게 효과를 적용하고 들어오는 드릴다운 뷰에 그림자를 적용하여 이전 뷰 위에 쌓이는 '카드'로 읽히도록 합니다.
/* MANDATORY: Wrap the animation block in @supports. Browsers without
scroll-driven animations still parse the @keyframes and would
apply the `to` state as a static style, leaving every view
permanently transformed. The @supports gate confines the animation
to browsers where it actually animates. */
@supports (animation-timeline: view()) {
.Stack-viewContent {
/* view(inline) tracks this element's progress through its nearest
scrollable ancestor on the inline (x) axis. */
animation: parallax linear both;
animation-timeline: view(inline);
/* Only animate the EXIT phase — when this view is being covered
by a deeper one. During its own entry the view stays at rest,
so the fresh content is fully bright and in position throughout. */
animation-range: exit 0% exit 100%;
}
/* Drill-down views (everything except the root) also get a shadow on
their left edge during the transition so they feel like cards
stacking over the previous view. */
.Stack-view:not(:first-child) .Stack-viewContent {
animation: parallax linear both, shadow-fade linear both;
animation-timeline: view(inline), view(inline);
/* parallax: only exit (the view sliding back as a deeper one comes in).
shadow-fade: entry through exit (visible the whole time the view is
transitioning, not when it's at rest). */
animation-range: exit 0% exit 100%, entry 0% exit 100%;
}
@keyframes parallax {
/* translateX(75%) and brightness(0.8) are examples — adjust to taste. */
to {
transform: translateX(75%);
filter: brightness(0.8);
}
}
@keyframes shadow-fade {
/* Shadow ramps in during entry, holds across the middle of the
gesture, and ramps out during exit — so it's only visible while
the view is mid-transition, not when at rest. */
0%, 100% { box-shadow: 0 0 1.5rem #0000; }
25%, 75% { box-shadow: 0 0 1.5rem #0004; }
}
}
참고: 이 효과는 최신 네이티브 스택 애플리케이션에서 널리 사용되며 좋은 출발점이 되지만, 필요에 따라 기존 전환 스타일에 맞게 정확한 시각 효과를 맞춤설정할 수 있습니다.
3. 모듈 상태
스택은 모듈 범위에서 네 가지 상태를 추적합니다.
const stack = document.querySelector('.Stack');
// Reference to the root view DOM element. The Stack starts empty, so
// this is null until the root view is created — either at init (step 8,
// when the URL is '/') or later by synthesizeRootEntry() (when the user
// landed on a deep link and then navigates back). Held as a mutable
// reference because other code (the scrollsnapchange handler, init, etc.)
// uses identity comparisons against it.
let rootView = null;
// Tracks which element to restore focus to when the user swipes back
// into a previous view. Keyed by the view element itself so entries
// are garbage-collected automatically when the view is pruned.
const returnFocus = new WeakMap();
// Maps history depth -> {urlPath, view}. We MUST maintain this map
// ourselves because the History API does not expose state for entries
// other than the current one — so when a view is pruned on swipe-back,
// we still need to remember which URL it represented in case the user
// later forward-navigates back into it.
const entriesByDepth = new Map();
// Tracked manually because history.state on a popstate event tells us
// the destination depth but not where we came from. We need both to
// compute the direction (back vs forward) and the distance.
let currentDepth = 0;
또한 앱의 라우팅 및 뷰 렌더링이 플러그인되는 유일한 위치인 세 가지 애플리케이션별 도우미가 있습니다.
// Resolve a URL path to the data your app needs to render the
// corresponding drill-down view, or return null for paths this section
// of the app does not handle (the root path '/', external links,
// unknown routes). resolveUrl() is for drill-down routes only — the
// root view is rendered separately by createRootView() below.
function resolveUrl(urlPath) {
// Replace with your routing logic. For example, match `/view/:id`
// and look the id up in your app state.
}
// Build the root (home) view of the stack. Application-specific content;
// preserve the .Stack-view / .Stack-viewContent wrapper structure (the
// inner element is required for the parallax — see step 2) and DO NOT
// render a back button — the root view has nothing behind it in the
// stack.
function createRootView() {
const view = document.createElement('div');
view.className = 'Stack-view';
view.innerHTML = `
<div class="Stack-viewContent">
<!-- Root content. Include <a href> elements pointing at URL paths
that resolveUrl() accepts, to enable drill-down from here. -->
</div>
`;
return view;
}
// Build a drill-down view DOM element from the resolved route data.
// Customize the inner content freely, but DO preserve the .Stack-view
// / .Stack-viewContent wrapper structure and DO include a back button
// (the swipe gesture only works on touch).
function createDrillDownView(routeData) {
const view = document.createElement('div');
view.className = 'Stack-view';
view.innerHTML = `
<div class="Stack-viewContent">
<header>
<button class="back" aria-label="Back"></button>
<!-- Title, breadcrumb, or other view chrome derived from
routeData. -->
</header>
<main>
<!-- View body, also from routeData. Include further <a href>
elements pointing at URL paths that resolveUrl() also
accepts, to enable additional drill-downs from this view. -->
</main>
</div>
`;
return view;
}
function getCurrentUrlPath() {
return location.pathname;
}
4. 드릴다운
드릴다운은 기록 항목 푸시, 새 뷰 빌드, 추가, 부드러운 스크롤 순으로 네 가지 작업을 실행합니다. 스냅이 정착되면 scrollsnapchange 핸들러 (7단계)가 거기서부터 시작합니다.
function drillDown(urlPath) {
const routeData = resolveUrl(urlPath);
if (!routeData) return;
const newDepth = currentDepth + 1;
// Push BEFORE creating the view so the URL is correct if anything
// observing history (analytics, etc.) reads it during view creation.
history.pushState({depth: newDepth}, '', urlPath);
// pushState truncates forward entries in real browser history;
// mirror that truncation in our depth map so we don't hold references
// to views the user can no longer reach.
for (const d of entriesByDepth.keys()) {
if (d >= newDepth) entriesByDepth.delete(d);
}
currentDepth = newDepth;
const newView = createDrillDownView(routeData);
stack.appendChild(newView);
entriesByDepth.set(newDepth, {urlPath, view: newView});
// Scroll one viewport-width to the right. behavior: 'auto' defers to
// the CSS `scroll-behavior` set in step 2, which is smooth unless
// prefers-reduced-motion is set. The snap container locks onto the
// new view; the scrollsnapchange listener (step 7) fires when the
// snap settles.
stack.scrollBy({left: stack.clientWidth, behavior: 'auto'});
}
5. 클릭 및 뒤로 버튼 처리
스택 내부의 링크 클릭을 가로채서 드릴다운으로 변환합니다. cmd/가운데 클릭으로 새 탭에서 링크를 열 수 있도록 수정자 키 동작을 유지합니다.
stack.addEventListener('click', (e) => {
// Back button: defer to goBack() (defined below), which handles both
// the normal in-app case and the deep-link case.
if (e.target.closest('.back')) {
goBack();
return;
}
// Drill-down link.
const link = e.target.closest('a');
if (!link || !stack.contains(link)) return;
// Let the browser handle modified clicks so users can open links
// in new tabs / windows. e.button !== 0 filters out middle-clicks.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
const urlPath = new URL(link.href).pathname;
const parentView = link.closest('.Stack-view');
// If the URL isn't handled by this section of the app (resolveUrl
// returns null), fall through so the browser navigates normally.
if (!resolveUrl(urlPath) || !parentView) return;
e.preventDefault();
// Record which link the user activated so focus can be restored to
// it when they swipe (or click) back into this view.
returnFocus.set(parentView, link);
drillDown(urlPath);
});
// Going back is usually just history.back(), but there's an important
// edge case: when the user lands directly on a deep-linked URL, there
// is no in-app history entry behind it. Calling history.back() in that
// situation would take them out of the app entirely. MANDATORY: detect
// this case and synthesize a root entry instead, so an in-app Back from
// a deep link lands on the root view and the platform Back from there
// returns the user to where they came from.
function goBack() {
const atDeepLinkRoot = currentDepth === 0
&& entriesByDepth.get(0)?.view !== rootView;
if (atDeepLinkRoot) {
synthesizeRootEntry();
} else {
// history.back() fires popstate, which routes through
// updateFromHistoryState (step 6) and scrolls the stack — the
// same path a swipe-back converges on.
history.back();
}
}
function synthesizeRootEntry() {
// Push a new history entry pointing at the root URL. This becomes the
// entry the user "came from"; the original deep-linked entry is now
// behind us, so platform Back from the root view will return there.
const newDepth = currentDepth + 1;
history.pushState({depth: newDepth}, '', '/');
// Create the root view if it doesn't exist yet (we landed on a deep
// link and never needed it before now), and insert it at the LEFT end
// of the stack. Adjust scrollLeft by one viewport width so the user's
// view doesn't visually jump — they should still be looking at the
// deep-linked view until the scroll animation below runs.
if (!rootView) {
rootView = createRootView();
stack.prepend(rootView);
stack.scrollLeft += stack.clientWidth;
}
entriesByDepth.set(newDepth, {urlPath: '/', view: rootView});
// Now scroll to the new entry (the root view). updateFromHistoryState
// smooth-scrolls one step left, the parallax plays, and
// scrollsnapchange fires when the root view settles.
updateFromHistoryState(history.state);
}
6. 기록에서 동기화 (popstate)
popstate는 사용자가 브라우저/OS 뒤로 또는 앞으로 버튼을 사용하거나 JavaScript가 history.back()/.go()를 호출할 때 실행됩니다. 이 핸들러는 기록 변경에 대한 응답으로 스택을 스크롤하는 유일한 경로입니다.
window.addEventListener('popstate', (event) => {
updateFromHistoryState(event.state);
});
function updateFromHistoryState(state, behaviorOverride) {
const newDepth = state?.depth ?? 0;
const urlPath = getCurrentUrlPath();
// Ensure entriesByDepth has an entry for the destination depth.
// If the URL changed (e.g. forward-nav into a previously-pruned
// view), clear the cached view reference so the loop below rebuilds.
const entry = entriesByDepth.get(newDepth) ?? {view: null};
if (entry.urlPath !== urlPath) {
entry.urlPath = urlPath;
entry.view = urlPath === '/' ? rootView : null;
}
entriesByDepth.set(newDepth, entry);
// Rebuild any views between root and the destination that were
// pruned earlier (when the user swiped back past them). Without
// this, forward-navigating to a previously-pruned view would have
// no element to scroll to.
for (let d = 0; d <= newDepth; d++) {
const e = entriesByDepth.get(d);
if (!e || e.view) continue;
const routeData = resolveUrl(e.urlPath);
if (!routeData) continue;
const rebuilt = createDrillDownView(routeData);
stack.appendChild(rebuilt);
e.view = rebuilt;
}
currentDepth = newDepth;
const targetView = entriesByDepth.get(newDepth)?.view;
if (!targetView) return;
// Compare destination index against current scroll position so we
// can bail if they're already aligned. This is reached when the
// scrollsnapchange handler below calls history.go() to sync history
// after a swipe-back that already completed visually — there's
// nothing more to scroll.
const toIdx = [...stack.children].indexOf(targetView);
const fromIdx = Math.round(stack.scrollLeft / stack.clientWidth);
if (fromIdx === toIdx) return;
// Pick a scroll behavior:
// - multi-step jumps (e.g. history.go(-3)): 'instant' to skip
// intermediate snap points — otherwise smooth-scrolling would
// fire scrollsnapchange for each one and do N rounds of
// state-transition work for no reason.
// - rightward (forward) single-step: 'instant'. Browser-forward is
// rare on the web and is often spurious (e.g. iOS Safari treats
// edge swipes as forward navigation, even with overscroll-behavior
// set). An instant swap reads as "snap" rather than a misleading
// drilldown animation the user didn't ask for. The user-initiated
// drill-down path (drillDown, step 4) is unaffected — it calls
// scrollBy directly and never reaches this code.
// - leftward (back) single-step: 'auto' so the CSS `scroll-behavior`
// (smooth unless prefers-reduced-motion is set — see step 2)
// applies. Back is the common, expected case and benefits from
// the animation.
// NOTE: "forward" here means spatial direction (toIdx > fromIdx),
// NOT depth direction. synthesizeRootEntry (step 5) pushes a new
// depth but scrolls LEFT to the root view, which correctly reads as
// back-style (smooth).
const forward = toIdx > fromIdx;
const multiStep = Math.abs(toIdx - fromIdx) > 1;
const behavior = behaviorOverride ?? (forward || multiStep ? 'instant' : 'auto');
stack.scrollTo({left: toIdx * stack.clientWidth, behavior});
}
7. scrollsnapchange: 단일 정보 소스
스와이프, 클릭 또는 프로그래매틱 스크롤로 트리거되는 모든 스냅 커밋 후 브라우저는 스크롤 컨테이너에서 scrollsnapchange 이벤트를 실행하고 새로 스냅된 요소는 event.snapTargetInline (가로 스냅의 경우)로 노출됩니다. 모든 상태 전환을 이 하나의 핸들러 내에 배치하면 스와이프 경로, 클릭 경로, popstate 경로가 일관되게 유지됩니다.
핸들러는 독립형 onActiveViewChanged 함수로 추출되므로 대체 (아래의 '대체 전략' 참고)에서 로직을 중복하지 않고 재사용할 수 있습니다.
function onActiveViewChanged(currentView) {
// Walk the stack in DOM order to update each view's role:
// - Views at or before currentView stay in the DOM but get
// `inert` (except currentView) so focus, pointer events, and
// AT navigation cannot leak into views hidden behind the
// parallax.
// - Views after currentView are unreachable (the user swiped
// back past them) so we drop them from the DOM to free memory.
// Their urlPath stays in entriesByDepth so a later forward
// navigation can rebuild the view from scratch.
let seenCurrent = false;
for (const view of [...stack.children]) {
if (seenCurrent) {
for (const e of entriesByDepth.values()) {
if (e.view === view) e.view = null;
}
view.remove();
} else {
// MANDATORY: inert non-current views. Without this, tabbing
// and screen-reader navigation can reach content hidden behind
// the parallax — a severe accessibility failure that's
// invisible to sighted users.
view.toggleAttribute('inert', view !== currentView);
if (view === currentView) seenCurrent = true;
}
}
// If the visible view's depth doesn't match `currentDepth`, the
// user got here by swiping (not clicking) — sync history so the
// browser back/forward buttons stay coherent with what's on screen.
let currentViewDepth;
for (const [d, e] of entriesByDepth) {
if (e.view === currentView) currentViewDepth = d;
}
if (currentViewDepth !== undefined && currentViewDepth !== currentDepth) {
// history.go fires popstate, which re-enters updateFromHistoryState.
// That call's fromIdx === toIdx check bails out without scrolling.
history.go(currentViewDepth - currentDepth);
}
// Restore focus on the now-active view:
// - If we recorded which link the user activated to drill out
// of this view, return focus there so a swipe-back lands them
// exactly where they left off.
// - Otherwise (a freshly-pushed drill-down view), move focus to
// the back button so keyboard users have an obvious next action.
// - preventScroll is REQUIRED: without it, .focus() scrolls the
// snap container to bring the focused element into view, which
// fights the snap and can land the user mid-snap.
const stored = returnFocus.get(currentView);
if (stored) {
stored.focus({preventScroll: true});
returnFocus.delete(currentView);
} else if (currentView !== rootView) {
currentView.querySelector('.back')?.focus({preventScroll: true});
}
}
stack.addEventListener('scrollsnapchange', (event) => {
// snapTargetInline is the element that was just snapped to on the
// inline (horizontal) axis. For this stack — where each view is one
// horizontal snap stop — that's the new active view.
onActiveViewChanged(event.snapTargetInline);
});
8. 초기화 (딥 링크 포함)
페이지가 로드될 때 URL이 이미 딥 뷰 (공유 링크, 북마크, 딥 페이지 새로고침)를 가리키고 있을 수 있습니다. URL과 일치하는 초기 뷰(루트 또는 딥 링크, 둘 다는 아님)를 빌드하고, 이를 빈 스택에 추가하고, 깊이 0 기록 항목을 시드하고, behavior: 'instant'를 사용하여 초기 스크롤 패스를 실행하여 첫 번째 페인트에서 패럴랙스가 애니메이션되지 않도록 합니다.
const initialUrlPath = getCurrentUrlPath();
const initialRouteData = resolveUrl(initialUrlPath);
// Build the initial view: a drill-down view if the URL maps to one,
// otherwise the root view. Whichever it is, that's the only view in
// the stack right now — the other will be created lazily by
// synthesizeRootEntry (step 5) or drillDown (step 4) if the user
// navigates to it.
let initialView;
if (initialRouteData) {
initialView = createDrillDownView(initialRouteData);
} else {
rootView = createRootView();
initialView = rootView;
}
stack.appendChild(initialView);
entriesByDepth.set(0, {urlPath: initialUrlPath, view: initialView});
// replaceState attaches a `depth` to the entry the user landed on, so
// any subsequent pushState / popstate has a base depth to count from.
history.replaceState({depth: 0}, '');
updateFromHistoryState(history.state, 'instant');
권장사항
- 스크롤 이벤트 좌표가 아닌
scrollsnapchange이벤트(IntersectionObserver대체 사용 - '대체 전략' 참고)를 'Active View 변경'의 정보 소스로 사용하세요. 스냅 커밋은 스와이프, 클릭, 프로그래매틱 스크롤,popstate경로에서 일관되게 실행되는 유일한 이벤트입니다. - 스냅 타겟 자체가 아닌 스냅 타겟의 하위 요소에 변환을 적용해야 합니다. 스냅 타겟의 변환이 스크롤 컨테이너의 스냅 도형에 다시 피드백되고 스크롤러가 동작 중간에 결함이 발생합니다.
- 현재 표시되는 뷰를 제외한 모든 뷰에
inert를 적용합니다(권장). 이 속성이 없으면 포커스와 스크린 리더 탐색이 패럴랙스 뒤에 숨겨진 뷰로 유출되어 시력이 있는 사용자에게는 보이지 않지만 심각한 접근성 실패가 발생합니다. - 권장사항: 모든 드릴다운에 기록 항목을 푸시하고
popstate를 처리하여 OS 수준 뒤로 동작과 브라우저 뒤로/앞으로 버튼이 작동하도록 합니다. 이 패턴은 네이티브 앱과 같은 느낌을 줍니다. - 권장사항: 스와이프 백이 깊이가
currentDepth와 일치하지 않는 뷰에 도달하면 active-view-changed 핸들러에서 기록을 조정합니다. 이 기능이 없으면 브라우저의 기록 커서가 화면에 표시되는 내용과 동기화되지 않으므로 후속 OS 뒤로 이동 시 사용자가 예기치 않은 위치로 돌아갑니다. - 권장사항: 사용자가 스와이프한 뷰를 DOM에서 프루닝합니다. 그렇지 않으면 긴 드릴다운 세션에서 분리된 하위 트리가 수십 개 누적될 수 있습니다.
entriesByDepth의 캐시된 URL 경로만으로도 앞으로 탐색이 해당 뷰로 돌아갈 때 뷰를 다시 빌드할 수 있습니다. - 스택 내에서 포커스를 복원할 때
.focus({preventScroll: true})를 호출해야 합니다. 기본preventScroll: false는 브라우저가 포커스 타겟을 뷰로 스크롤하도록 하여 스냅 컨테이너와 충돌하고 사용자를 스냅 중간에 배치할 수 있습니다. - 권장사항: URL을 공유하고 새 탭에서 열 수 있도록 내부 링크에서 cmd/ctrl/마우스 휠 클릭을 유지합니다.
- 다단계 기록 점프 및 공간 정방향 popstate 전환 (
toIdx > fromIdx)에behavior: 'instant'를 사용합니다. 그렇지 않으면 다단계 점프는 모든 중간 스냅에서scrollsnapchange를 실행하고 N회의 비활성/포커스/기록 작업을 실행합니다. 앞으로 이동하는 popstate는 허위인 경우가 많습니다. iOS Safari는overscroll-behavior-x: none가 설정되어 있어도 가장자리 스와이프를 브라우저 앞으로 이동으로 취급하며, 즉시 전환은 사용자가 시작하지 않은 '드릴다운' 애니메이션보다 오해의 소지가 훨씬 적습니다. 사용자가 시작한 드릴다운 경로 (drillDown)는popstate를 통해서가 아니라 직접 스크롤되므로 영향을 받지 않습니다. prefers-reduced-motion를 준수해야 합니다.@media (prefers-reduced-motion: no-preference)내에서만scroll-behavior: smooth를 선언하고'smooth'가 아닌behavior: 'auto'를 사용하여scrollTo/scrollBy를 호출합니다. 그러면 호출별 JS 분기 없이 OS 수준 환경설정이 적용됩니다.behavior: 'smooth'를 하드코딩하면 사용자의 설정이 우회됩니다.<button onclick>또는<div>가 아닌 실제<a href>요소를 드릴다운 트리거로 렌더링해야 합니다. 실제 앵커는 마우스 오버 시 URL 미리보기, 공유 가능성, 마우스 가운데 버튼 클릭, 스크린 리더 역할, SEO를 무료로 제공합니다.- 모든 드릴다운 뷰에 명시적인 뒤로 버튼을 포함합니다. 스와이프 동작은 터치에서만 작동하므로 키보드, 포인터, 데스크톱 사용자에게는 표시되는 어포던스가 필요합니다.
popstate핸들러에서history.pushState를 호출하지 마세요. 사용자가 뒤로 이동하려고 할 때 새 항목이 푸시되고 브라우저 뒤로 버튼이 중단됩니다.- 스크롤 기반 애니메이션을 사용할 수 있는 경우
scroll이벤트 리스너로 패럴랙스를 구동하지 마세요. CSS 경로는 컴포지터에서 실행됩니다. JS 스크롤 리스너는 기본 스레드에서 실행되며 동작 중에 프레임이 눈에 띄게 떨어집니다. - 스와이프백 후 DOM에서 삭제한 뷰를 변경하지 마세요.
entriesByDepth를 표준 레코드로 처리합니다. 프루닝된 항목에는view: null가 있으며updateFromHistoryState에서 요청 시 다시 빌드됩니다.
대체 전략
대체 기능이 필요할 수 있는 기능은 scroll-snap-events와 scroll-driven-animations입니다. 두 기능 모두 강력한 대체 기능 또는 점진적 개선 스토리가 있으며 이 사용 사례에 사용해도 안전합니다.
스크롤 스냅 이벤트
scrollsnapchange 이벤트는 Active View 변경을 감지하는 가장 깔끔한 방법입니다. 스택에 리스너가 하나 있고 스냅 커밋당 정확히 한 번 실행됩니다. 이 기능이 없는 브라우저에서는 스택 내부의 전체 공개 상태를 위해 각 뷰를 감시하는 IntersectionObserver를 사용하여 동일한 효과를 폴리필할 수 있습니다. 대체는 기본 경로가 사용하는 것과 동일한 onActiveViewChanged 함수로 디스패치되므로 모든 상태 전환 로직이 한곳에 유지됩니다.
// MANDATORY when supporting browsers that haven't shipped scroll-snap-events
// yet. Check `HTMLElement.prototype` (not `window` or `document`) — the
// event handler IDL attribute is added to the prototype when the feature
// is supported, regardless of whether any element has the handler set.
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
const viewObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
// threshold:1 only fires for fully-visible entries, but the
// observer also emits a "leaving" entry per view that drops below
// ratio 1. Filter to the entering side, which is the snap-commit
// moment we're trying to detect.
if (entry.intersectionRatio === 1) {
onActiveViewChanged(entry.target);
}
}
}, {root: stack, threshold: 1});
// Auto-observe every .Stack-view as it's added to the stack, and stop
// observing as it's removed. Using a MutationObserver lets the primary
// code (drillDown, updateFromHistoryState, synthesizeRootEntry, init)
// stay free of fallback wiring.
new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.classList?.contains('Stack-view')) viewObserver.observe(node);
}
for (const node of m.removedNodes) {
if (node.classList?.contains('Stack-view')) viewObserver.unobserve(node);
}
}
}).observe(stack, {childList: true});
// Catch up to any views already in the stack at the time this code
// runs (typically the initial view appended in step 8).
for (const view of stack.children) viewObserver.observe(view);
}
스크롤 기반 애니메이션
스크롤 기반 패럴랙스/어둡게/그림자 효과는 탐색 핵심 기능에 추가된 점진적 개선입니다. CSS @supports (animation-timeline: view()) 게이트 (2단계에 표시됨)는 애니메이션을 지원 브라우저로 제한합니다. 다른 모든 곳에서는 뷰가 전환 없이 스냅 정지 지점 간에 단순히 잘립니다. 패럴랙스가 없어도 구성요소는 완벽하게 작동합니다. 스냅, 기록 동기화, 포커스 관리, inert는 모두 계속 작동합니다.
이전 기준 타겟에 패럴랙스 대체가 필요한 경우 scroll 리스너를 스택에 연결하고 스크롤포트를 통한 각 뷰의 진행 상황을 설명하는 CSS 맞춤 속성을 작성한 다음 해당 속성에서 transform 및 filter를 구동합니다.
if (!CSS.supports('animation-timeline: view()')) {
stack.addEventListener('scroll', () => {
const viewWidth = stack.clientWidth;
for (const view of stack.children) {
// Progress: 0 when this view is centered, 1 when it has fully
// exited to the left. Matches the @keyframes mapping above.
const offsetLeft = view.offsetLeft - stack.scrollLeft;
const progress = Math.min(1, Math.max(0, -offsetLeft / viewWidth));
const content = view.querySelector('.Stack-viewContent');
content.style.transform = `translateX(${progress * 75}%)`;
content.style.filter = `brightness(${1 - progress * 0.2})`;
}
});
}