Przegląd
Przechodzenie do szczegółów stosu to hierarchiczny wzorzec nawigacji, powszechny w aplikacjach mobilnych, w którym aktywowanie linku powoduje wyświetlenie nowego widoku pełnoekranowego nad poprzednim. Zawartość widoku jest definiowana przez aplikację – może to być podstrona ustawień, wątek w kanale, folder w przeglądarce plików, strona z informacjami w galerii itp. Użytkownik może wrócić, przesuwając bieżący widok poza ekran w prawo lub klikając przycisk Wstecz. Historia przeglądarki jest synchronizowana, dzięki czemu gest cofania na poziomie systemu operacyjnego, precyzyjne linki i przechodzenie wstecz/do przodu działają spójnie.
W tym przewodniku stosujemy następujący stos:
- Poziomy kontener CSS z przewijaniem z przyciąganiem, w którym każdy widok jest dokładnie jednym punktem przyciągania. Przechodzenie do szczegółów powoduje dołączenie nowego widoku i płynne przewinięcie do niego. Gest przesunięcia palcem do tyłu jest obsługiwany natywnie przez przeglądarkę, co zapewnia śledzenie pędu, szybkości i przerwania bez użycia kodu JavaScript zdarzenia wskaźnika.
- Detektor zdarzeń
scrollsnapchangena stosie, który jest uruchamiany, gdy zmienia się cel przyciągania. Jest to jedyne źródło wiarygodnych danych o zmianie aktywnego widoku, więc przesunięcie, kliknięcie, automatyczne przewijanie i ścieżkipopstatezbiegają się w jednym wywołaniu zwrotnym, które aktualizujeinert, przywraca zaznaczenie, usuwa widoki, które użytkownik pominął, i uzgadnia historię przeglądarki. - Integracja
pushState/popstate, dzięki której każde przejście do szczegółów dodaje wpis do historii, gest wstecz na poziomie systemu operacyjnego działa, a precyzyjne linki otwierają się bezpośrednio w odpowiednim widoku. - (Opcjonalna) animacja
view()sterowana przewijaniem w każdym widoku, która tworzy efekt paralaksy, przyciemnienia i cienia powiązany bezpośrednio z gestem przesunięcia, dzięki czemu widoczny ruch jest sterowany palcem użytkownika, a nie klatką pośrednią.
To podejście jest preferowane w porównaniu z animacjami transform opartymi na języku JavaScript, ponieważ mechanizm przyciągania daje użytkownikowi bezpośrednią kontrolę nad pozycją panelu za pomocą gestów (steruje nim palec, a nie klatka pośrednia) i odpowiada wzorcom interakcji, których użytkownicy oczekują od natywnych aplikacji mobilnych.
Implementacja
1. Znacznik
Statyczny kod HTML to tylko pusty kontener stosu. Widoki są tworzone w języku JavaScript i dołączane, gdy użytkownik się porusza.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Każdy widok jest bezpośrednim elementem podrzędnym .Stack-view elementu .Stack (celu przyciągania) z opakowaniem .Stack-viewContent wewnątrz (gdzie znajduje się zawartość widoku i gdzie stosuje się przekształcenie paralaksy – patrz krok 2). W każdym momencie stos zawiera po jednym widoku na każdy aktywny wpis historii aktywności, od lewej do prawej w kolejności przechodzenia do szczegółów. Gdy użytkownik przejdzie o 2 poziomy w głąb od poziomu głównego, wyrenderowany DOM będzie wyglądać tak:
<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>
Uwagi:
- Wszystkie widoki oprócz aktualnie widocznego mają atrybut
inert. Jest on stosowany/usuwany automatycznie przez moduł obsługiscrollsnapchangew kroku 7 – nie ustawiaj go w kreatorach widoków. - Widoki, które użytkownik przesunął wstecz, są usuwane z DOM (również w kroku 7), więc stos nigdy nie przekracza
currentDepth + 1elementów podrzędnych. Są one odbudowywane na żądanie z ich ścieżek adresów URL zapisanych w pamięci podręcznej, jeśli nawigacja do przodu do nich powróci.
2. Style
Pasek przewijania stosu
Stos to pozioma siatka, w której każdy widok podrzędny ma dokładnie taką samą szerokość jak kontener, a funkcja CSS scroll-snap wymusza wyświetlanie jednego widoku na przyciągnięcie. Dzięki temu gest przesunięcia wstecz jest bardziej naturalny.
.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;
}
Efekt „stosu” (paralaksa / przyciemnienie / cień)
Animacja view(inline) sterowana przewijaniem śledzi postęp każdego widoku w przewijaniu stosu i stosuje efekt paralaksy i przyciemnienia do widoku wyjściowego oraz cień do widoków wejściowych, dzięki czemu są one odczytywane jako „karty” nakładające się na poprzedni widok.
/* 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; }
}
}
UWAGA: ten efekt jest popularny w nowoczesnych aplikacjach ze stosami natywnymi i jest dobrym punktem wyjścia, ale dokładne efekty wizualne można dostosować do istniejących stylów przejścia.
3. Stan modułu
Stos śledzi 4 elementy stanu w zakresie modułu:
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;
oraz 3 funkcje pomocnicze związane z aplikacją – jedyne miejsca, w których podłączone są routing i renderowanie widoku aplikacji:
// 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. Przejdź do bardziej szczegółowego widoku
Przejście do szczegółów wykonuje 4 czynności w tej kolejności: dodaje wpis do historii, tworzy nowy widok, dołącza go i płynnie przewija do niego. Gdy przyciąganie się zakończy, moduł obsługi scrollsnapchange (krok 7) przejmuje kontrolę.
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. Obsługa kliknięć i przycisku Wstecz
Przechwytywanie kliknięć linków w stosie i przekształcanie ich w rozwinięcia. Zachowaj działanie klawisza modyfikującego, aby kliknięcie klawisza cmd lub środkowego przycisku myszy nadal otwierało link w nowej karcie.
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. Synchronizuj z historii (popstate)
popstate jest wywoływane, gdy użytkownik używa przycisku wstecz lub dalej w przeglądarce lub systemie operacyjnym albo gdy JavaScript wywołuje history.back() / .go(). Ten moduł obsługi jest jedyną ścieżką, która przewija stos w odpowiedzi na zmianę historii.
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: jedno źródło wiarygodnych danych
Po każdym zatwierdzeniu przyciągania – niezależnie od tego, czy zostało wywołane przez przesunięcie, kliknięcie czy przewijanie automatyczne – przeglądarka wywołuje zdarzenie scrollsnapchange w kontenerze przewijania, a nowo przyciągnięty element jest widoczny jako event.snapTargetInline (w przypadku przyciągania poziomego). Umieszczenie wszystkich przejść między stanami w tym jednym module obsługi zapewnia spójność ścieżki przesunięcia, ścieżki kliknięcia i ścieżki popstate.
Moduł obsługi jest wyodrębniany do samodzielnej funkcji onActiveViewChanged, dzięki czemu funkcja zastępcza (patrz „Strategie zastępcze” poniżej) może go używać bez powielania logiki.
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. inicjowanie (w tym precyzyjne linki),
Gdy strona się wczytuje, adres URL może już wskazywać na widok szczegółowy (udostępniony link, zakładka, odświeżenie strony podrzędnej). Utwórz widok początkowy, który pasuje do adresu URL – główny lub z głębokim linkiem, ale nigdy oba – dołącz go do pustego stosu, dodaj wpis historii o głębokości 0 i uruchom początkowe przewijanie z behavior: 'instant', aby paralaksa nie animowała się przy pierwszym renderowaniu.
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');
Sprawdzone metody
- UŻYWAJ zdarzenia
scrollsnapchange(z rezerwowym zdarzeniemIntersectionObserver– patrz „Strategie rezerwowe”) jako źródła informacji o zmianie aktywnego widoku, a nie współrzędnych zdarzenia przewijania. Zatwierdzenie przyciągania to jedyne zdarzenie, które jest konsekwentnie wywoływane w przypadku przesunięcia, kliknięcia, automatycznego przewijania i ścieżekpopstate. - DO apply transforms to a child of the snap target, never to the snap target itself. Przekształcenie celu przyciągania wpływa na geometrię przyciągania kontenera przewijania, co powoduje błędy podczas przewijania.
- ZASTOSUJ
inertdo wszystkich widoków oprócz obecnie widocznego. Bez tego fokus i nawigacja czytnika ekranu będą przechodzić do widoków ukrytych za paralaksą – niewidocznych dla użytkowników widzących, ale stanowiących poważny problem z dostępnością. - DODAJ wpis historii przy każdym przejściu do szczegółów i obsługuj zdarzenie
popstate, aby gest Wstecz na poziomie systemu operacyjnego oraz przyciski Wstecz/Dalej w przeglądarce działały prawidłowo. Dzięki temu wzorzec przypomina aplikację natywną. - DO reconcile history from the active-view-changed handler when a swipe-back lands on a view whose depth doesn't match
currentDepth. Bez tego kolejne naciśnięcie przycisku Wstecz w systemie operacyjnym przeniesie użytkownika w nieoczekiwane miejsce, ponieważ kursor historii przeglądarki nie jest zsynchronizowany z tym, co jest na ekranie. - USUWAJ z DOM widoki, które użytkownik pominął. W przeciwnym razie długa sesja analizy szczegółowej może spowodować nagromadzenie dziesiątek odłączonych poddrzew. Ścieżka adresu URL w pamięci podręcznej w
entriesByDepthwystarczy do ponownego utworzenia dowolnego widoku, jeśli nawigacja do przodu do niego powróci. - DO call
.focus({preventScroll: true})when restoring focus inside the stack. Domyślna wartośćpreventScroll: falsepowoduje, że przeglądarka przewija element docelowy do widoku, co koliduje z kontenerem przyciągania i może spowodować, że użytkownik znajdzie się w połowie przyciągania. - ZACHOWAJ możliwość kliknięcia linków wewnętrznych z użyciem klawisza Cmd/Ctrl lub środkowego przycisku myszy, aby adresy URL można było udostępniać i otwierać w nowej karcie.
- UŻYWAJ
behavior: 'instant'do wieloetapowych przeskoków w historii ORAZ do przejść popstate do przodu w przestrzeni (toIdx > fromIdx). W przeciwnym razie wieloetapowe przeskoki będą wywoływaćscrollsnapchangeprzy każdym pośrednim przyciągnięciu i wykonywać N rund pracy związanej z bezwładnością, fokusem i historią. Zdarzenia popstate do przodu są często fałszywe – iOS Safari traktuje przesunięcia krawędzi jako przejście do przodu w przeglądarce nawet przy ustawieniuoverscroll-behavior-x: none, a natychmiastowa zamiana jest znacznie mniej myląca niż animowanie „rozwinięcia”, którego użytkownik nie zainicjował. Ścieżka szczegółowa zainicjowana przez użytkownika (drillDown) nie ulega zmianie, ponieważ przewija się bezpośrednio, a nie przezpopstate. - DO respect
prefers-reduced-motion: declarescroll-behavior: smoothonly inside@media (prefers-reduced-motion: no-preference)and callscrollTo/scrollBywithbehavior: 'auto'(not'smooth') so the OS-level preference takes effect without per-call JS branching. Zakodowanie na stałe parametrubehavior: 'smooth'powoduje pominięcie ustawienia użytkownika. - DO render real
<a href>elements as drill-down triggers, not<button onclick>or<div>. Prawdziwe kotwice zapewniają podgląd adresu URL po najechaniu kursorem, możliwość udostępniania, kliknięcie środkowym przyciskiem myszy, rolę czytnika ekranu i SEO bezpłatnie. - DO include an explicit back button in every drill-down view. Gest przesuwania działa tylko na ekranie dotykowym – użytkownicy klawiatury, wskaźnika i komputera potrzebują widocznego elementu interfejsu.
- NIE wywołuj
history.pushStatez modułu obsługipopstate– spowoduje to dodanie nowych wpisów, gdy użytkownik będzie próbował wrócić, i zepsuje działanie przycisku Wstecz w przeglądarce. - NIE używaj detektora zdarzeń
scrolldo sterowania efektem paralaksy, gdy dostępne są animacje sterowane przewijaniem. Ścieżka CSS działa w kompozytorze, a detektor przewijania JS działa w głównym wątku i będzie widocznie gubić klatki podczas gestu. - NIE zmieniaj widoków usuniętych z DOM po przesunięciu palcem wstecz. Traktuj
entriesByDepthjako rekord kanoniczny: przycięty wpis maview: nulli jest odbudowywany na żądanie wupdateFromHistoryState.
Strategie zastępcze
Funkcje, które mogą wymagać kreacji zastępczych, to scroll-snap-events i scroll-driven-animations. Obie mają solidne kreacje zastępcze lub historie progresywnego ulepszania i można ich bezpiecznie używać w tym przypadku:
Zdarzenia przewijania z przyciąganiem
Zdarzenie scrollsnapchange to najprostszy sposób na wykrycie zmiany widoku aktywnego – 1 detektor na stosie, uruchamiany dokładnie raz na zatwierdzenie przyciągania. W przeglądarkach, które nie obsługują tej funkcji, ten sam efekt można uzyskać za pomocą kodu polyfill z IntersectionObserver, który obserwuje każdy widok, aby zapewnić pełną widoczność w stosie. Zastępcza ścieżka wysyła dane do tej samej funkcji onActiveViewChanged, której używa ścieżka podstawowa, dzięki czemu cała logika przejścia stanu pozostaje w jednym miejscu.
// 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);
}
Animacje oparte na przewijaniu
Efekt paralaksy, przyciemnienia lub cienia wywoływany przez przewijanie to progresywne ulepszenie podstawowej nawigacji. Bramka CSS @supports (animation-timeline: view()) (pokazana w kroku 2) ogranicza animację do obsługiwanych przeglądarek. W pozostałych przypadkach widoki po prostu przełączają się między punktami przyciągania bez przejścia. Komponent jest w pełni funkcjonalny bez paralaksy – przyciąganie, synchronizacja historii, zarządzanie fokusem i inert nadal działają.
Jeśli w przypadku starszych wersji Baseline wymagany jest efekt paralaksy, do stosu dołącz detektor scroll i napisz niestandardową właściwość CSS opisującą postęp każdego widoku w obszarze przewijania, a następnie określ transform i filter na podstawie tej właściwości:
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})`;
}
});
}