Ringkasan
Penelusuran stack adalah pola navigasi hierarkis, yang umum dalam aplikasi seluler, tempat mengaktifkan link akan mendorong tampilan layar penuh baru di atas tampilan sebelumnya. Konten tampilan ditentukan oleh aplikasi — subhalaman setelan, rangkaian pesan di dalam feed, folder di dalam browser file, halaman detail di dalam galeri, dll. Pengguna kembali dengan menggeser tampilan saat ini ke luar layar ke kanan atau dengan mengetuk tombol kembali. Histori browser tetap disinkronkan sehingga gestur Kembali tingkat OS, deep link, dan navigasi kembali/maju semuanya berfungsi secara koheren.
Panduan ini mengimplementasikan stack sebagai:
- Penampung scroll-snap CSS horizontal dengan setiap tampilan adalah tepat satu snap stop. Melihat perincian akan menambahkan tampilan baru dan men-scroll-nya dengan lancar; gestur geser kembali ditangani secara native oleh browser, sehingga memberikan momentum, kecepatan, dan pelacakan gangguan secara gratis tanpa JavaScript peristiwa pointer.
- Pemroses peristiwa
scrollsnapchangedi stack yang diaktifkan saat target snap berubah. Ini digunakan sebagai satu sumber tepercaya untuk "tampilan aktif berubah" — sehingga jalur geser, klik, scroll terprogram, danpopstatesemuanya bertemu dalam satu callback yang memperbaruiinert, memulihkan fokus, memangkas tampilan yang digeser pengguna, dan merekonsiliasi histori penjelajahan. - Integrasi
pushState/popstatesehingga setiap penelusuran menambahkan entri histori, gestur Kembali di tingkat OS berfungsi, dan deep link terbuka langsung ke tampilan yang tepat. - Animasi
view()berbasis scroll (opsional) di setiap tampilan yang menghasilkan efek paralaks + redup + bayangan yang terkait langsung dengan gestur geser, sehingga gerakan yang terlihat didorong oleh jari pengguna, bukan tween.
Pendekatan ini lebih disukai daripada animasi transform berbasis JavaScript karena mekanisme snap memberi pengguna kontrol gestur langsung atas posisi panel (jari mereka yang menggerakkannya, bukan tween) dan cocok dengan pola interaksi yang diharapkan pengguna dari aplikasi seluler native.
Penerapan
1. Markup
HTML statis hanyalah penampung tumpukan kosong; tampilan dibuat di JavaScript dan ditambahkan saat pengguna melakukan navigasi.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Setiap tampilan adalah turunan langsung .Stack-view dari .Stack (target snap) dengan wrapper .Stack-viewContent di dalamnya (tempat konten tampilan berada, dan tempat transformasi paralaks diterapkan — lihat langkah 2). Setiap saat, stack memiliki satu tampilan per entri histori aktif, dari kiri ke kanan dalam urutan perincian. Setelah pengguna melihat perincian dua tingkat dari root, DOM yang dirender akan terlihat seperti ini:
<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>
Catatan:
- Semua tampilan kecuali yang saat ini terlihat memiliki atribut
inert. Ini diterapkan/dihapus secara otomatis oleh pengendaliscrollsnapchangedi langkah 7 — jangan tetapkan dari builder tampilan Anda. - Tampilan yang digeser kembali oleh pengguna akan dihapus dari DOM (juga dengan langkah 7) sehingga tumpukan tidak pernah bertambah melebihi
currentDepth + 1turunan. Halaman ini dibuat ulang sesuai permintaan dari jalur URL yang di-cache jika navigasi maju kembali ke halaman tersebut.
2. Gaya
Scroller tumpukan
Stack adalah petak horizontal dengan setiap tampilan turunan memiliki lebar yang sama persis dengan penampung, dengan scroll-snap CSS yang menerapkan satu tampilan per snap. Hal ini memberikan nuansa native pada gestur geser kembali.
.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;
}
Efek "tumpukan" (paralaks/redup/bayangan)
Animasi view(inline) berbasis scroll melacak progres setiap tampilan melalui scroller tumpukan dan menerapkan paralaks + peredupan ke tampilan yang keluar, serta bayangan pada tampilan perincian yang masuk sehingga terbaca sebagai "kartu" yang menumpuk di atas tampilan sebelumnya.
/* 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; }
}
}
CATATAN: Efek ini populer di aplikasi stack native modern dan merupakan titik awal yang baik, tetapi efek visual yang tepat dapat disesuaikan agar sesuai dengan gaya transisi yang ada sesuai kebutuhan.
3. Status modul
Stack melacak empat bagian status dalam cakupan modul:
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;
Ditambah tiga helper khusus aplikasi — satu-satunya tempat untuk memasukkan perutean dan rendering tampilan aplikasi Anda:
// 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. Lihat perincian
Penelusuran melakukan empat hal dalam urutan ini: mengirim entri histori, membuat tampilan baru, menambahkannya, men-scroll dengan lancar ke tampilan tersebut. Pengendali scrollsnapchange (langkah 7) akan melanjutkan dari sana setelah snap selesai.
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. Penanganan klik dan tombol kembali
Mencegat klik link di dalam stack dan mengonversinya menjadi perincian. Pertahankan perilaku tombol pengubah sehingga cmd/klik tengah tetap membuka link di tab baru.
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. Sinkronkan dari histori (popstate)
popstate diaktifkan saat pengguna menggunakan tombol kembali atau maju browser/OS, atau saat JavaScript memanggil history.back()/.go(). Pengendali ini adalah satu-satunya jalur yang men-scroll stack sebagai respons terhadap perubahan histori.
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: satu sumber tepercaya
Setelah setiap commit snap — baik dipicu oleh geser, klik, atau scroll terprogram — browser akan mengaktifkan peristiwa scrollsnapchange pada penampung scroll, dengan elemen yang baru di-snap ditampilkan sebagai event.snapTargetInline (untuk snap horizontal). Menempatkan semua transisi status di dalam satu pengendali ini akan menjaga jalur geser, jalur klik, dan jalur popstate tetap koheren.
Pengendali diekstrak ke dalam fungsi onActiveViewChanged mandiri sehingga penggantian (lihat "Strategi penggantian" di bawah) dapat menggunakannya kembali tanpa menduplikasi logika.
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. Inisialisasi (termasuk deep link)
Saat halaman dimuat, URL mungkin sudah mengarah ke tampilan dalam (link yang dibagikan, bookmark, pemuatan ulang di halaman dalam). Buat tampilan awal mana pun yang cocok dengan URL — root atau deep link, tetapi tidak keduanya — tambahkan ke stack kosong, masukkan entri histori kedalaman 0, dan jalankan penerusan scroll awal dengan behavior: 'instant' agar paralaks tidak dianimasikan pada render pertama.
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');
Praktik terbaik
- LAKUKAN penggunaan peristiwa
scrollsnapchange(dengan penggantianIntersectionObserver— lihat "Strategi penggantian") sebagai sumber kebenaran untuk "tampilan aktif berubah", bukan koordinat peristiwa scroll. Snap commit adalah satu-satunya peristiwa yang diaktifkan secara konsisten di seluruh jalur geser, klik, scroll terprogram, danpopstate. - LAKUKAN penerapan transformasi ke turunan target snap, jangan pernah ke target snap itu sendiri. Transformasi pada target snap akan diumpankan kembali ke geometri snap penampung scroll dan scroller akan mengalami gangguan di tengah gestur.
- TERAPKAN
inertke setiap tampilan kecuali tampilan yang saat ini terlihat. Tanpa ini, fokus dan navigasi pembaca layar akan bocor ke tampilan yang tersembunyi di balik paralaks — tidak terlihat oleh pengguna yang dapat melihat, tetapi merupakan kegagalan aksesibilitas yang parah. - LAKUKAN penambahan entri histori pada setiap penelusuran dan tangani
popstateagar gestur Kembali di tingkat OS dan tombol kembali/maju browser berfungsi. Hal inilah yang membuat pola terasa seperti aplikasi native. - LAKUKAN rekonsiliasi histori dari pengendali active-view-changed saat geser kembali mendarat di tampilan yang kedalamannya tidak cocok dengan
currentDepth. Tanpa ini, tombol Kembali OS berikutnya akan mengembalikan pengguna ke tempat yang tidak terduga karena kursor histori browser tidak sinkron dengan apa yang ada di layar. - LAKUKAN pemangkasan tampilan yang telah digeser oleh pengguna dari DOM. Sesi lihat perincian yang panjang dapat mengakumulasi puluhan subhierarki yang terlepas. Jalur URL yang di-cache di
entriesByDepthsudah cukup untuk membuat ulang tampilan apa pun jika navigasi maju kembali ke tampilan tersebut. - LAKUKAN panggilan
.focus({preventScroll: true})saat memulihkan fokus di dalam stack.preventScroll: falsedefault membuat browser men-scroll target fokus ke tampilan, yang bertentangan dengan penampung snap dan dapat membuat pengguna berada di tengah-tengah snap. - LAKUKAN pertahankan cmd/ctrl/klik tengah pada link internal sehingga URL tetap dapat dibagikan dan dibuka di tab baru.
- LAKUKAN penggunaan
behavior: 'instant'untuk lompatan histori multi-langkah DAN untuk transisi popstate spasial maju (toIdx > fromIdx). Jika tidak, lompatan multi-langkah akan memicuscrollsnapchangedi setiap snap perantara dan melakukan N putaran pekerjaan inert/fokus/histori. Popstate maju sering kali palsu — iOS Safari memperlakukan geser tepi sebagai maju browser meskipunoverscroll-behavior-x: noneditetapkan, dan pertukaran instan jauh lebih tidak menyesatkan daripada menganimasikan "pemerincian" yang tidak dimulai oleh pengguna. Jalur lihat perincian yang dimulai pengguna (drillDown) tidak terpengaruh karena di-scroll secara langsung, bukan melaluipopstate. - LAKUKAN penghormatan terhadap
prefers-reduced-motion: deklarasikanscroll-behavior: smoothhanya di dalam@media (prefers-reduced-motion: no-preference)dan panggilscrollTo/scrollBydenganbehavior: 'auto'(bukan'smooth') sehingga preferensi tingkat OS diterapkan tanpa percabangan JS per panggilan. Melakukan hard code padabehavior: 'smooth'akan mengabaikan setelan pengguna. - LAKUKAN rendering elemen
<a href>yang sebenarnya sebagai pemicu lihat perincian, bukan<button onclick>atau<div>. Anchor asli mendapatkan pratinjau URL saat kursor diarahkan ke atasnya, kemampuan berbagi, klik tengah, peran pembaca layar, dan SEO secara gratis. - LAKUKAN: sertakan tombol kembali yang eksplisit di setiap tampilan perincian. Gestur geser hanya berfungsi pada sentuhan — pengguna keyboard, pointer, dan desktop memerlukan affordance yang terlihat.
- JANGAN panggil
history.pushStatedari pengendalipopstate— yang akan mengirim entri baru saat pengguna mencoba kembali dan merusak tombol kembali browser. - JANGAN menggerakkan paralaks dengan pemroses peristiwa
scrolljika animasi berbasis scroll tersedia. Jalur CSS berjalan di compositor; pemroses scroll JS berjalan di thread utama dan akan terlihat menurunkan frame selama gestur. - JANGAN mengubah tampilan yang Anda hapus dari DOM setelah geser kembali. Perlakukan
entriesByDepthsebagai data kanonis: entri yang dipangkas memilikiview: nulldan dibuat ulang sesuai permintaan diupdateFromHistoryState.
Strategi penggantian
Fitur yang mungkin memerlukan penggantian adalah scroll-snap-events dan scroll-driven-animations, yang keduanya memiliki penggantian yang andal atau progressive enhancement, dan aman digunakan untuk kasus penggunaan ini:
Peristiwa pengepasan posisi scroll
Peristiwa scrollsnapchange adalah cara paling rapi untuk mendeteksi "tampilan aktif berubah" — satu pemroses di stack, diaktifkan tepat satu kali per snap commit. Di browser yang tidak memilikinya, efek yang sama dapat di-polyfill dengan IntersectionObserver yang mengamati setiap tampilan untuk visibilitas penuh di dalam stack. Fallback dikirim ke fungsi onActiveViewChanged yang sama dengan yang digunakan jalur utama, sehingga semua logika transisi status tetap berada di satu tempat.
// 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);
}
Animasi berbasis scroll
Efek paralaks/redup/bayangan yang didorong scroll adalah progressive enhancement di atas inti navigasi. Gerbang @supports (animation-timeline: view()) CSS (ditampilkan di langkah 2) membatasi animasi ke browser yang mendukung; di tempat lain, tampilan hanya beralih di antara perhentian snap tanpa transisi. Komponen ini berfungsi penuh tanpa paralaks — snap, sinkronisasi histori, pengelolaan fokus, dan inert semuanya masih berfungsi.
Jika penggantian paralaks diperlukan untuk target dasar pengukuran yang lebih lama, lampirkan pemroses scroll ke stack dan tulis properti kustom CSS yang menjelaskan progres setiap tampilan melalui scrollport, lalu jalankan transform dan filter dari properti tersebut:
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})`;
}
});
}