Panoramica
Un'esplorazione in dettaglio dello stack è un pattern di navigazione gerarchico, comune nelle app mobile, in cui l'attivazione di un link inserisce una nuova visualizzazione a schermo intero sopra quella precedente. I contenuti della visualizzazione sono definiti dall'applicazione: una pagina secondaria delle impostazioni, un thread all'interno di un feed, una cartella all'interno di un browser di file, una pagina dei dettagli all'interno di una galleria e così via. L'utente torna indietro scorrendo la visualizzazione corrente fuori dallo schermo verso destra o toccando un pulsante Indietro. La cronologia del browser rimane sincronizzata, in modo che il gesto Indietro a livello di sistema operativo, i deep link e la navigazione a ritroso funzionino in modo coerente.
Questa guida implementa lo stack come:
- Un container di allineamento dello scorrimento CSS orizzontale in cui ogni visualizzazione è esattamente un punto di aggancio. La visualizzazione in dettaglio aggiunge una nuova visualizzazione e la scorre in modo fluido; il gesto di scorrimento indietro è gestito in modo nativo dal browser, fornendo monitoraggio di slancio, velocità e interruzione senza costi e senza JavaScript per gli eventi del puntatore.
- Un listener di eventi
scrollsnapchangenello stack che si attiva quando cambia il target di aggancio. Viene utilizzato come Single Source Of Truth per "la visualizzazione attiva è cambiata", quindi i percorsi di scorrimento, clic, scorrimento programmatico epopstateconvergono tutti in un unico callback che aggiornainert, ripristina lo stato attivo, elimina le visualizzazioni che l'utente ha superato con lo scorrimento e riconcilia la cronologia di navigazione. - Un'integrazione
pushState/popstatein modo che ogni visualizzazione in dettaglio aggiunga una voce della cronologia, il gesto Indietro a livello di sistema operativo funzioni e i deep link si aprano direttamente nella visualizzazione corretta. - Un'animazione
view()(facoltativa) basata sullo scorrimento in ogni visualizzazione che produce un effetto di parallasse + attenuazione + ombra legato direttamente al gesto di scorrimento, in modo che il movimento visibile sia guidato dal dito dell'utente, non da un'interpolazione.
Questo approccio è preferibile alle animazioni transform basate su JavaScript perché il meccanismo di aggancio offre all'utente il controllo diretto dei gesti sulla posizione del riquadro (il dito lo guida, non un tween) e corrisponde ai pattern di interazione che gli utenti si aspettano dalle app mobile native.
Implementazione
1. Segni e linee
L'HTML statico è solo un contenitore di stack vuoto; le visualizzazioni sono create in JavaScript e aggiunte man mano che l'utente naviga.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Ogni visualizzazione è un elemento secondario diretto .Stack-view di .Stack (il target di aggancio) con un wrapper .Stack-viewContent al suo interno (dove si trovano i contenuti della visualizzazione e dove viene applicata la trasformazione di parallasse, vedi il passaggio 2). In qualsiasi momento, lo stack ha una visualizzazione per ogni voce della cronologia attiva, da sinistra a destra in ordine di visualizzazione dettagliata. Dopo che l'utente ha eseguito l'esplorazione in dettaglio di due livelli dalla radice, il DOM visualizzato ha il seguente aspetto:
<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>
Note:
- Tutte le visualizzazioni, ad eccezione di quella attualmente visibile, hanno l'attributo
inert. Questo viene applicato/rimosso automaticamente dal gestorescrollsnapchangenel passaggio 7. Non impostarlo dai generatori di visualizzazioni. - Le visualizzazioni che l'utente scorre indietro vengono rimosse dal DOM (anche dal passaggio 7), quindi lo stack non supera mai i
currentDepth + 1elementi secondari. Vengono ricostruite on demand dai percorsi degli URL memorizzati nella cache se la navigazione in avanti torna a queste pagine.
2. Stili
Scroller dello stack
Lo stack è una griglia orizzontale in cui ogni vista secondaria ha esattamente la larghezza del contenitore, con l'allineamento dello scorrimento CSS che applica una vista per allineamento. Questo è ciò che conferisce al gesto di scorrimento indietro un aspetto nativo.
.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;
}
L'effetto "pila" (parallasse/attenuazione/ombra)
Un'animazione view(inline) basata sullo scorrimento monitora l'avanzamento di ogni visualizzazione attraverso lo scroller dello stack e applica un effetto di parallasse e oscuramento alla visualizzazione in uscita, oltre a un'ombra sulle visualizzazioni di drill-down in entrata, in modo che vengano lette come "schede" impilate sulla visualizzazione precedente.
/* 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; }
}
}
NOTA: questo effetto è molto diffuso nelle moderne applicazioni con stack nativo ed è un buon punto di partenza, ma gli effetti visivi esatti possono essere personalizzati in base agli stili di transizione esistenti, se necessario.
3. Stato del modulo
Lo stack monitora quattro elementi di stato nell'ambito del modulo:
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;
Inoltre, tre helper specifici per l'applicazione, gli unici punti in cui si collegano il routing e il rendering delle visualizzazioni dell'app:
// 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. Visualizza in dettaglio
Un drill-down esegue quattro operazioni in questo ordine: inserisce una voce nella cronologia, crea la nuova visualizzazione, la aggiunge e la scorre in modo fluido. Il gestore scrollsnapchange (passaggio 7) riprende da lì una volta che lo snap si è stabilizzato.
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. Gestione dei clic e del pulsante Indietro
Intercetta i clic sui link all'interno dello stack e convertili in visualizzazioni in dettaglio. Preserva il comportamento dei tasti di modifica in modo che Cmd/clic con il tasto centrale del mouse apra comunque il link in una nuova scheda.
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. Sincronizza dalla cronologia (popstate)
popstate viene attivato quando l'utente utilizza il pulsante Indietro o Avanti del browser/sistema operativo o quando JavaScript chiama history.back()/.go(). Questo gestore è l'unico percorso che scorre lo stack in risposta a una modifica della cronologia.
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: la Single Source Of Truth
Dopo ogni commit di aggancio, attivato da uno scorrimento, un clic o uno scorrimento programmatico, il browser attiva un evento scrollsnapchange sul container di scorrimento, con l'elemento appena agganciato esposto come event.snapTargetInline (per l'aggancio orizzontale). Inserire tutte le transizioni di stato all'interno di questo singolo gestore è ciò che mantiene coerenti il percorso di scorrimento, il percorso di clic e il percorso popstate.
Il gestore viene estratto in una funzione onActiveViewChanged autonoma in modo che il fallback (vedi "Strategie di fallback" di seguito) possa riutilizzarlo senza duplicare la logica.
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. Inizializzazione (inclusi i deep link)
Al caricamento pagina, l'URL potrebbe già puntare a una visualizzazione approfondita (un link condiviso, un preferito, un aggiornamento su una deep page). Crea la visualizzazione iniziale corrispondente all'URL, principale o con deep link, ma mai entrambi, aggiungila allo stack vuoto, inserisci la voce della cronologia di profondità 0 ed esegui un passaggio di scorrimento iniziale con behavior: 'instant' in modo che l'effetto parallasse non venga animato al primo paint.
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');
Best practice
- USA l'evento
scrollsnapchange(con un fallbackIntersectionObserver, vedi "Strategie di fallback") come fonte attendibile per "la visualizzazione attiva è cambiata", non le coordinate dell'evento di scorrimento. Snap commit è l'unico evento che si attiva in modo coerente tra i percorsi di scorrimento, clic, scorrimento programmatico epopstate. - SÌ: applica le trasformazioni a un elemento secondario del target di aggancio, mai al target di aggancio stesso. Una trasformazione sul target di aggancio viene restituita alla geometria di aggancio del container di scorrimento e lo scroller presenta un glitch a metà del gesto.
- SÌ: applica
inerta ogni visualizzazione, ad eccezione di quella attualmente visibile. Senza questo, lo stato attivo e la navigazione con screen reader si estendono alle visualizzazioni nascoste dietro l'effetto parallasse, invisibili agli utenti vedenti, ma un grave errore di accessibilità. - SÌ: inserisci una voce della cronologia in ogni visualizzazione in dettaglio e gestisci
popstatein modo che il gesto Indietro a livello di sistema operativo e i pulsanti Avanti/Indietro del browser funzionino. Questo è ciò che fa sembrare il pattern un'app nativa. - DO: riconcilia la cronologia dal gestore active-view-changed quando uno scorrimento indietro arriva su una visualizzazione la cui profondità non corrisponde a
currentDepth. In caso contrario, un successivo ritorno al sistema operativo riporta l'utente in un punto imprevisto perché il cursore della cronologia del browser non è sincronizzato con ciò che è visualizzato sullo schermo. - SÌ: elimina dal DOM le visualizzazioni che l'utente ha ignorato. In caso contrario, una lunga sessione di visualizzazione in dettaglio può accumulare decine di sottoalberi scollegati. Il percorso dell'URL memorizzato nella cache in
entriesByDepthè sufficiente per ricostruire qualsiasi visualizzazione se la navigazione in avanti torna a questa. - CHIAMA
.focus({preventScroll: true})quando ripristini lo stato attivo all'interno dello stack. Il valore predefinitopreventScroll: falsefa sì che il browser scorra il target di attivazione nella visualizzazione, il che contrasta con il container di aggancio e può far atterrare l'utente a metà dell'aggancio. - SÌ: mantieni cmd/ctrl/clic con il tasto centrale del mouse sui link interni in modo che gli URL rimangano condivisibili e apribili in una nuova scheda.
- DO use
behavior: 'instant'for multi-step history jumps AND for spatial-forward popstate transitions (toIdx > fromIdx). Multi-step jumps would otherwise firescrollsnapchangeat every intermediate snap and do N rounds of inert/focus/history work. I popstate di avanzamento sono spesso spuri: iOS Safari tratta gli scorrimenti dei bordi come avanzamento del browser anche conoverscroll-behavior-x: noneimpostato e uno scambio istantaneo è molto meno fuorviante rispetto all'animazione di un "drilldown" non avviato dall'utente. Il percorso di visualizzazione in dettaglio avviato dall'utente (drillDown) non è interessato perché lo scorrimento avviene direttamente, non tramitepopstate. - 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. L'hardcoding dibehavior: 'smooth'bypassa l'impostazione dell'utente. - SÌ: esegui il rendering di elementi
<a href>reali come trigger di drill-down, non<button onclick>o<div>. I veri anchor offrono senza costi l'anteprima dell'URL al passaggio del mouse, la condivisibilità, il clic con il tasto centrale, il ruolo di screen reader e la SEO. - SÌ: includi un pulsante Indietro esplicito in ogni visualizzazione di drill-down. Il gesto di scorrimento funziona solo sui dispositivi touch: gli utenti di tastiera, puntatore e computer hanno bisogno di un affordance visibile.
- NON chiamare
history.pushStatedal gestorepopstate, in quanto ciò inserisce nuove voci mentre l'utente sta tentando di tornare indietro e interrompe il pulsante Indietro del browser. - NON gestire l'effetto parallasse con un listener di eventi
scrollquando sono disponibili animazioni basate sullo scorrimento. Il percorso CSS viene eseguito sul compositore; un listener di scorrimento JS viene eseguito sul thread principale e perderà visibilmente i frame durante il gesto. - NON modificare le visualizzazioni rimosse dal DOM dopo uno scorrimento indietro. Considera
entriesByDepthcome il record canonico: una voce eliminata haview: nulle viene ricostruita on demand inupdateFromHistoryState.
Strategie di riserva
Le funzionalità che potrebbero richiedere dei fallback sono scroll-snap-events e scroll-driven-animations, entrambe con storie di fallback o potenziamento progressivo solide e sicure da utilizzare per questo caso d'uso:
Eventi di allineamento dello scorrimento
L'evento scrollsnapchange è il modo più pulito per rilevare "la modifica della visualizzazione attiva": un listener nello stack, attivato esattamente una volta per ogni commit di snap. Nei browser che non lo supportano, lo stesso effetto può essere ottenuto con un polyfill utilizzando un IntersectionObserver che monitora ogni visualizzazione per garantire la visibilità completa all'interno dello stack. Il fallback viene inviato alla stessa funzione onActiveViewChanged utilizzata dal percorso principale, in modo che tutta la logica di transizione di stato rimanga in un unico posto.
// 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);
}
Animazioni basate sullo scorrimento
L'effetto di parallasse/oscuramento/ombra basato sullo scorrimento è un potenziamento progressivo rispetto al nucleo della navigazione. Il gate CSS @supports (animation-timeline: view()) (mostrato nel passaggio 2) limita l'animazione ai browser supportati; ovunque, le visualizzazioni si alternano semplicemente tra i punti di aggancio senza transizione. Il componente è completamente funzionale senza l'effetto parallasse: lo snap, la sincronizzazione della cronologia, la gestione dello stato attivo e inert continuano a funzionare.
Se è necessario un fallback di parallasse per i target di riferimento meno recenti, collega un listener scroll allo stack e scrivi una proprietà personalizzata CSS che descriva l'avanzamento di ogni visualizzazione nello scrollport, quindi genera transform e filter da quella proprietà:
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})`;
}
});
}