ภาพรวม
การเจาะลึกแบบสแต็กคือรูปแบบการนำทางตามลำดับชั้นที่พบได้ทั่วไปในแอปบนอุปกรณ์เคลื่อนที่ ซึ่งการเปิดใช้งานลิงก์จะแสดงมุมมองแบบเต็มหน้าจอใหม่ที่ด้านบนของมุมมองก่อนหน้า เนื้อหาของมุมมองจะกำหนดโดยแอปพลิเคชัน เช่น หน้าย่อยของการตั้งค่า เทรดภายในฟีด โฟลเดอร์ภายในเบราว์เซอร์ไฟล์ หน้ารายละเอียดภายในแกลเลอรี เป็นต้น ผู้ใช้จะกลับไปโดยปัดมุมมองปัจจุบันออกจากหน้าจอไปทางขวาหรือแตะปุ่มย้อนกลับ ประวัติการเข้าชมของเบราว์เซอร์จะซิงค์กันอยู่เสมอเพื่อให้ท่าทางสัมผัสเพื่อย้อนกลับในระดับระบบปฏิบัติการ, Deep Link และการนำทางไปข้างหน้า/ย้อนกลับทำงานร่วมกันได้อย่างราบรื่น
คู่มือนี้จะใช้สแต็กดังนี้
- คอนเทนเนอร์สแนปการเลื่อน CSS แนวนอนที่แต่ละมุมมองเป็นจุดหยุดสแนป 1 จุดพอดี การเจาะลึกจะเพิ่มมุมมองใหม่และเลื่อนอย่างราบรื่นไปยังมุมมองนั้น เบราว์เซอร์จะจัดการท่าทางสัมผัสการปัดกลับโดยตรง ซึ่งจะช่วยให้ติดตามโมเมนตัม ความเร็ว และการหยุดชะงักได้โดยไม่ต้องใช้ JavaScript ของเหตุการณ์เคอร์เซอร์
- Listener เหตุการณ์
scrollsnapchangeในสแต็กที่เริ่มทำงานเมื่อเป้าหมายสแนปเปลี่ยนไป ซึ่งใช้เป็นแหล่งข้อมูลที่เชื่อถือได้เพียงแหล่งเดียว (SSOT) สำหรับ "มุมมองแอ็กทีฟมีการเปลี่ยนแปลง" ดังนั้นการปัด การคลิก การเลื่อนแบบเป็นโปรแกรม และเส้นทางpopstateทั้งหมดจะรวมกันในการเรียกกลับเดียวที่อัปเดตinert, คืนค่าโฟกัส, ตัดมุมมองที่ผู้ใช้ปัดผ่าน และปรับยอดประวัติการท่องเว็บ - การผสานรวม
pushState/popstateเพื่อให้การเจาะลึกทุกครั้งเพิ่มรายการประวัติ ท่าทางสัมผัส "กลับ" ระดับระบบปฏิบัติการทำงานได้ และ Deep Link จะเปิดมุมมองที่ถูกต้องโดยตรง - ภาพเคลื่อนไหว
view()ที่ขับเคลื่อนด้วยการเลื่อน (ไม่บังคับ) ในแต่ละมุมมองที่สร้างเอฟเฟกต์พารัลแลกซ์ + หรี่แสง + เงาที่เชื่อมโยงกับท่าทางสัมผัสการปัดโดยตรง เพื่อให้การเคลื่อนไหวที่มองเห็นได้ขับเคลื่อนด้วยนิ้วของผู้ใช้ ไม่ใช่การทวีน
เราขอแนะนำให้ใช้วิธีนี้มากกว่าภาพเคลื่อนไหว transform ที่ขับเคลื่อนด้วย JavaScript เนื่องจากกลไกการสแนปช่วยให้ผู้ใช้ควบคุมตำแหน่งของแผงได้โดยตรงด้วยท่าทางสัมผัส (ใช้นิ้วควบคุม ไม่ใช่การทวีน) และตรงกับรูปแบบการโต้ตอบที่ผู้ใช้คาดหวังจากแอปที่มากับมือถือ
การใช้งาน
1. Markup
HTML แบบคงที่เป็นเพียงคอนเทนเนอร์สแต็กที่ว่างเปล่า โดยมุมมองจะสร้างขึ้นใน JavaScript และต่อท้ายเมื่อผู้ใช้ไปยังส่วนต่างๆ
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
แต่ละมุมมองเป็นองค์ประกอบย่อยโดยตรง .Stack-view ของ .Stack (เป้าหมายสแนป) โดยมี Wrapper .Stack-viewContent อยู่ภายใน (ซึ่งเป็นที่อยู่ของเนื้อหามุมมอง และเป็นที่ที่ใช้การเปลี่ยนรูปแบบพารัลแลกซ์ โปรดดูขั้นตอนที่ 2) สแต็กจะมี 1 มุมมองต่อรายการประวัติที่ใช้งานอยู่เสมอ โดยเรียงจากซ้ายไปขวาตามลำดับการเจาะลึก หลังจากที่ผู้ใช้เจาะลึกเข้าไป 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ตัวแฮนเดิลscrollsnapchangeจะใช้/นำออกโดยอัตโนมัติในขั้นตอนที่ 7 อย่าตั้งค่าจาก View Builder - ระบบจะนำมุมมองที่ผู้ใช้ปัดย้อนกลับออกไปจาก DOM (ในขั้นตอนที่ 7 ด้วย) เพื่อไม่ให้สแต็กมีจำนวนเกินกว่า
currentDepth + 1รายการ ระบบจะสร้างหน้าเว็บขึ้นมาใหม่ตามคำขอจากเส้นทาง URL ที่แคชไว้หากการไปยังส่วนต่างๆ ข้างหน้ากลับมาที่หน้าเว็บเหล่านั้น
2. รูปแบบ
ตัวเลื่อนสแต็ก
สแต็กคือตารางกริดแนวนอนที่มุมมองย่อยแต่ละรายการมีความกว้างเท่ากับคอนเทนเนอร์พอดี โดยมีการสแนปการเลื่อน CSS ที่บังคับใช้ 1 มุมมองต่อการสแนป ซึ่งจะช่วยให้ท่าทางสัมผัสเพื่อปัดย้อนกลับให้ความรู้สึกเป็นธรรมชาติ
.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. สถานะโมดูล
สแต็กจะติดตามสถานะ 4 ส่วนในขอบเขตโมดูล ดังนี้
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;
รวมถึงตัวช่วยเฉพาะแอปพลิเคชัน 3 รายการ ซึ่งเป็นที่เดียวที่การกำหนดเส้นทางและการแสดงผลมุมมองของแอปจะเชื่อมต่ออยู่ ดังนี้
// 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. เจาะลึก
การเจาะลึกจะทำ 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 จะเริ่มทำงานเมื่อผู้ใช้ใช้ปุ่มย้อนกลับหรือไปข้างหน้าของเบราว์เซอร์/ระบบปฏิบัติการ หรือเมื่อ 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. การเริ่มต้น (รวมถึง Deep Link)
เมื่อหน้าเว็บโหลดขึ้นมา URL อาจชี้ไปยังมุมมองที่อยู่ลึก (ลิงก์ที่แชร์ บุ๊กมาร์ก การรีเฟรชในหน้าเว็บที่อยู่ลึก) อยู่แล้ว สร้างมุมมองเริ่มต้นที่ตรงกับ URL ไม่ว่าจะเป็นรูทหรือที่ทำ Deep Link แต่ต้องไม่ใช้ทั้ง 2 อย่าง ต่อท้ายมุมมองดังกล่าวกับสแต็กว่าง เริ่มต้นรายการประวัติความลึก 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เป็นทางเลือกสำรอง โปรดดู "กลยุทธ์สำรอง") เป็นแหล่งข้อมูลที่เชื่อถือได้สำหรับ "มุมมองแอ็กทีฟมีการเปลี่ยนแปลง" ไม่ใช่พิกัดของเหตุการณ์การเลื่อน Snap commit เป็นเหตุการณ์เดียวที่เริ่มทำงานอย่างสม่ำเสมอในเส้นทางการปัด การคลิก การเลื่อนแบบเป็นโปรแกรม และpopstate - ควรใช้การเปลี่ยนรูปแบบกับองค์ประกอบย่อยของเป้าหมายการสแนป ไม่ควรใช้กับเป้าหมายการสแนปโดยตรง การเปลี่ยนรูปแบบในเป้าหมายสแนปจะป้อนกลับไปยังเรขาคณิตสแนปของคอนเทนเนอร์การเลื่อน และตัวเลื่อนจะเกิดข้อผิดพลาดระหว่างท่าทางสัมผัส
- ควรใช้
inertกับทุกมุมมอง ยกเว้นมุมมองที่มองเห็นได้ในปัจจุบัน หากไม่มีสิ่งนี้ การโฟกัสและการไปยังส่วนต่างๆ ของโปรแกรมอ่านหน้าจอจะรั่วไหลไปยังมุมมองที่ซ่อนอยู่หลังพารัลแลกซ์ ซึ่งผู้ใช้ที่มองเห็นจะไม่เห็น แต่เป็นความล้มเหลวด้านการช่วยเหลือพิเศษที่รุนแรง - ควรพุชรายการประวัติในการเจาะลึกข้อมูลทุกครั้งและจัดการ
popstateเพื่อให้ท่าทางสัมผัสย้อนกลับระดับระบบปฏิบัติการและปุ่มย้อนกลับ/ไปข้างหน้าของเบราว์เซอร์ทำงานได้ ซึ่งจะทำให้รูปแบบนี้ให้ความรู้สึกเหมือนแอปที่มาพร้อมเครื่อง - ควรปรับยอดประวัติจากแฮนเดิล active-view-changed เมื่อการปัดกลับไปยังมุมมองที่มีความลึกไม่ตรงกับ
currentDepthหากไม่มีการดำเนินการนี้ การกดปุ่มย้อนกลับของระบบปฏิบัติการในภายหลังจะนำผู้ใช้ไปยังตำแหน่งที่ไม่คาดคิด เนื่องจากเคอร์เซอร์ประวัติของเบราว์เซอร์ไม่ซิงค์กับสิ่งที่อยู่บนหน้าจอ - ควรตัดมุมมองที่ผู้ใช้ปัดผ่านออกจาก DOM มิฉะนั้น เซสชันการเจาะลึกที่ยาวนานอาจสะสมซับทรีที่แยกออกมาหลายสิบรายการ เส้นทาง URL ที่แคชไว้ใน
entriesByDepthเพียงพอที่จะสร้างมุมมองใหม่หากการไปยังส่วนต่างๆ ข้างหน้ากลับมาที่มุมมองนั้น - ควรเรียกใช้
.focus({preventScroll: true})เมื่อคืนค่าโฟกัสภายในสแต็กpreventScroll: falseเริ่มต้นจะทำให้เบราว์เซอร์เลื่อนเป้าหมายที่โฟกัสไปยังมุมมอง ซึ่งจะขัดแย้งกับคอนเทนเนอร์สแนปและอาจทำให้ผู้ใช้สแนปได้ไม่สมบูรณ์ - ควรคงการคลิก cmd/ctrl/คลิกกลางในลิงก์ภายในไว้เพื่อให้ URL ยังคงแชร์ได้และเปิดในแท็บใหม่ได้
- ควรใช้
behavior: 'instant'สำหรับการข้ามประวัติแบบหลายขั้นตอนและสำหรับการเปลี่ยนผ่าน popstate เชิงพื้นที่แบบไปข้างหน้า (toIdx > fromIdx) มิฉะนั้น การข้ามแบบหลายขั้นตอนจะเริ่มทำงานscrollsnapchangeที่สแนปกลางทุกรายการ และทำงานแบบเฉื่อย/โฟกัส/ประวัติ N รอบ Popstate แบบส่งต่อมักเป็นเท็จ โดย iOS Safari จะถือว่าการปัดขอบเป็นการส่งต่อเบราว์เซอร์แม้ว่าจะตั้งค่าoverscroll-behavior-x: noneไว้ก็ตาม และการสลับทันทีจะทำให้เข้าใจผิดน้อยกว่าการแสดงภาพเคลื่อนไหว "เจาะลึก" ที่ผู้ใช้ไม่ได้เริ่ม เส้นทางการเจาะลึกที่ผู้ใช้เริ่ม (drillDown) จะไม่ได้รับผลกระทบเนื่องจากจะเลื่อนโดยตรง ไม่ใช่ผ่านpopstate - ควรเคารพ
prefers-reduced-motion: ประกาศscroll-behavior: smoothภายใน@media (prefers-reduced-motion: no-preference)เท่านั้น และเรียกใช้scrollTo/scrollByด้วยbehavior: 'auto'(ไม่ใช่'smooth') เพื่อให้ค่ากำหนดระดับระบบปฏิบัติการมีผลโดยไม่ต้องแยกสาขา JS ต่อการเรียกใช้ การฮาร์ดโค้ดbehavior: 'smooth'จะข้ามการตั้งค่าของผู้ใช้ - ควรแสดงผลองค์ประกอบ
<a href>จริงเป็นทริกเกอร์การเจาะลึก ไม่ใช่<button onclick>หรือ<div>Anchor จริงจะแสดงตัวอย่าง URL เมื่อวางเมาส์เหนือลิงก์, ความสามารถในการแชร์, การคลิกกลาง, บทบาทของโปรแกรมอ่านหน้าจอ และ SEO โดยไม่มีค่าใช้จ่าย - ควรใส่ปุ่มย้อนกลับที่ชัดเจนในทุกมุมมองแบบเจาะลึก ท่าทางสัมผัสแบบปัดใช้ได้กับการสัมผัสเท่านั้น ผู้ใช้แป้นพิมพ์ ตัวชี้ และเดสก์ท็อปต้องมีสิ่งที่บ่งบอกถึงการใช้งานที่มองเห็นได้
- อย่าเรียกใช้
history.pushStateจากเครื่องจัดการpopstateซึ่งจะพุชรายการใหม่ขณะที่ผู้ใช้พยายามย้อนกลับและทำให้ปุ่มย้อนกลับของเบราว์เซอร์ใช้งานไม่ได้ - อย่าขับเคลื่อนพารัลแลกซ์ด้วย Listener เหตุการณ์
scrollเมื่อมีภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อน เส้นทาง CSS จะทำงานบน Compositor ส่วน Listener การเลื่อน JS จะทำงานในเทรดหลักและจะแสดงเฟรมที่หายไปอย่างเห็นได้ชัดระหว่างการใช้ท่าทางสัมผัส - อย่าเปลี่ยนมุมมองที่คุณนำออกจาก DOM หลังจากปัดกลับ ถือว่า
entriesByDepthเป็นระเบียน Canonical: รายการที่ตัดแต่งแล้วจะมีview: nullและสร้างใหม่ตามต้องการในupdateFromHistoryState
กลยุทธ์สำรอง
ฟีเจอร์ที่อาจต้องใช้การสำรองคือ scroll-snap-events และ scroll-driven-animations ซึ่งทั้ง 2 ฟีเจอร์มีเรื่องราวการสำรองหรือการเพิ่มประสิทธิภาพแบบต่อเนื่องที่แข็งแกร่ง และปลอดภัยสำหรับการใช้งานใน Use Case นี้
เหตุการณ์การสแนปการเลื่อน
เหตุการณ์ scrollsnapchange เป็นวิธีที่ชัดเจนที่สุดในการตรวจจับ "มุมมองแอ็กทีฟมีการเปลี่ยนแปลง" โดยมี Listener 1 รายในสแต็ก ซึ่งจะเริ่มทำงานเพียงครั้งเดียวต่อการคอมมิตสแนปชอต ในเบราว์เซอร์ที่ไม่มีฟีเจอร์นี้ คุณสามารถใช้ Polyfill เพื่อให้ได้ผลลัพธ์เดียวกันด้วย 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);
}
ภาพเคลื่อนไหวที่ขับเคลื่อนด้วยการเลื่อน
เอฟเฟกต์พารัลแลกซ์/หรี่แสง/เงาที่ขับเคลื่อนด้วยการเลื่อนเป็นการเพิ่มประสิทธิภาพแบบต่อเนื่องที่ด้านบนของแกนหลักของการนำทาง เกต @supports (animation-timeline: view()) ของ CSS (แสดงในขั้นตอนที่ 2) จะจำกัดภาพเคลื่อนไหวไว้เฉพาะเบราว์เซอร์ที่รองรับ ส่วนเบราว์เซอร์อื่นๆ จะตัดมุมมองระหว่างจุดหยุดสแนปโดยไม่มีการเปลี่ยนฉาก คอมโพเนนต์จะทำงานได้อย่างเต็มรูปแบบโดยไม่มีพารัลแลกซ์ ทั้งการสแนป การซิงค์ประวัติ การจัดการโฟกัส และ inert จะยังคงทำงานได้ตามปกติ
หากจำเป็นต้องใช้ Parallax สำรองสำหรับเป้าหมาย Baseline ที่เก่ากว่า ให้แนบ Listener 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})`;
}
});
}