Descripción general
Un desglose de pila es un patrón de navegación jerárquico, común en las apps para dispositivos móviles, en el que la activación de un vínculo genera una nueva vista de pantalla completa sobre la anterior. El contenido de la vista se define en la aplicación: una subpágina de configuración, un hilo dentro de un feed, una carpeta dentro de un navegador de archivos, una página de detalles dentro de una galería, etc. El usuario regresa deslizando la vista actual fuera de la pantalla hacia la derecha o presionando un botón Atrás. El historial del navegador se mantiene sincronizado para que el gesto de retroceso a nivel del SO, los vínculos directos y la navegación hacia adelante y hacia atrás funcionen de manera coherente.
En esta guía, se implementa la pila de la siguiente manera:
- Un contenedor de ajuste de desplazamiento de CSS horizontal en el que cada vista es exactamente una parada de ajuste. Cuando se desglosa, se agrega una vista nueva y se desplaza de forma fluida hacia ella. El gesto de deslizar hacia atrás se controla de forma nativa en el navegador, lo que proporciona un seguimiento de impulso, velocidad e interrupción de forma gratuita sin JavaScript de eventos de puntero.
- Un objeto de escucha de eventos
scrollsnapchangeen la pila que se activa cuando cambia el destino de ajuste. Esto se usa como la fuente única de información para "la vista activa cambió", por lo que las rutas de deslizamiento, clic, desplazamiento programático ypopstateconvergen en una devolución de llamada que actualizainert, restablece el foco, quita las vistas que el usuario deslizó y concilia el historial de navegación. - Una integración de
pushState/popstatepara que cada desglose agregue una entrada de historial, funcione el gesto Atrás a nivel del SO y los vínculos directos se abran directamente en la vista correcta. - Una animación
view()opcional controlada por el desplazamiento en cada vista que produce un efecto de paralaje, atenuación y sombra vinculado directamente al gesto de deslizamiento, de modo que el movimiento visible lo controla el dedo del usuario, no una interpolación.
Este enfoque se prefiere por sobre las animaciones de transform controladas por JavaScript, ya que el mecanismo de ajuste le brinda al usuario un control gestual directo de la posición del panel (su dedo lo controla, no una interpolación) y coincide con los patrones de interacción que los usuarios esperan de las aplicaciones nativas para dispositivos móviles.
Implementación
1. Marca
El HTML estático es solo un contenedor de pila vacío; las vistas se compilan en JavaScript y se agregan a medida que el usuario navega.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Cada vista es un elemento secundario directo .Stack-view de .Stack (el objetivo de ajuste) con un wrapper .Stack-viewContent dentro de él (donde se encuentra el contenido de la vista y donde se aplica la transformación de paralaje; consulta el paso 2). En cualquier momento, la pila tiene una vista por entrada de historial activa, de izquierda a derecha en orden de desglose. Después de que el usuario haya profundizado dos niveles desde la raíz, el DOM renderizado se verá de la siguiente manera:
<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>
Notas:
- Todas las vistas, excepto la que está visible actualmente, llevan el atributo
inert. El controladorscrollsnapchangelo aplica o quita automáticamente en el paso 7. No lo configures desde tus compiladores de vistas. - Las vistas que el usuario desliza hacia atrás se quitan del DOM (también en el paso 7), por lo que la pila nunca crece más allá de los elementos secundarios de
currentDepth + 1. Se vuelven a compilar a pedido a partir de sus rutas de URL almacenadas en caché si la navegación hacia adelante vuelve a ellas.
2. Estilos
El desplazador de pila
La pila es una cuadrícula horizontal en la que cada vista secundaria tiene exactamente el ancho del contenedor, con el ajuste de desplazamiento de CSS que aplica una vista por ajuste. Esto es lo que le da al gesto de deslizar hacia atrás su aspecto 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;
}
El efecto de "pila" (paralaje, atenuación o sombra)
Una animación view(inline) controlada por el desplazamiento hace un seguimiento del progreso de cada vista a través del desplazador de pila y aplica un paralaje y una atenuación a la vista que sale, además de una sombra en las vistas de desglose entrantes para que se lean como "tarjetas" que se apilan sobre la vista anterior.
/* 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: Este efecto es popular en las aplicaciones modernas de pila nativa y es un buen punto de partida, pero los efectos visuales exactos se pueden personalizar para que se adapten a los estilos de transición existentes según sea necesario.
3. Estado del módulo
La pila hace un seguimiento de cuatro partes del estado en el alcance del módulo:
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;
Además, hay tres ayudantes específicos de la aplicación, que son los únicos lugares donde se conectan el enrutamiento y la renderización de vistas de tu 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. Desglosar información
Un desglose hace cuatro cosas en este orden: envía una entrada del historial, compila la vista nueva, la agrega y se desplaza de forma fluida hacia ella. El controlador scrollsnapchange (paso 7) continúa desde allí una vez que se establece la instantánea.
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. Control de clics y del botón Atrás
Intercepta los clics en los vínculos dentro de la pila y conviértelos en desgloses. Preserva el comportamiento de las teclas modificadoras para que Cmd o el clic central sigan abriendo el vínculo en una pestaña nueva.
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. Sincronizar desde el historial (popstate)
popstate se activa cuando el usuario usa el botón Atrás o Adelante del navegador o del SO, o cuando JavaScript llama a history.back() o .go(). Este controlador es la única ruta que desplaza la pila en respuesta a un cambio en el historial.
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 fuente única de información
Después de cada confirmación de ajuste, ya sea que se active con un deslizamiento, un clic o un desplazamiento programático, el navegador activa un evento scrollsnapchange en el contenedor de desplazamiento, y el elemento recién ajustado se expone como event.snapTargetInline (para el ajuste horizontal). Colocar todas las transiciones de estado dentro de este controlador es lo que mantiene coherentes la ruta de deslizamiento, la ruta de clic y la ruta de popstate.
El controlador se extrae en una función onActiveViewChanged independiente para que el resguardo (consulta "Estrategias de resguardo" más abajo) pueda reutilizarlo sin duplicar la lógica.
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. Inicialización (incluidos los vínculos directos)
Cuando se carga la página, es posible que la URL ya apunte a una vista profunda (un vínculo compartido, un favorito o una actualización en una página profunda). Compila la vista inicial que coincida con la URL (raíz o con vínculo directo, pero nunca ambas), agrégala a la pila vacía, propaga la entrada del historial de profundidad 0 y ejecuta un pase de desplazamiento inicial con behavior: 'instant' para que el paralaje no se anime en la primera renderización.
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');
Prácticas recomendadas
- SÍ uses el evento
scrollsnapchange(con un resguardo deIntersectionObserver; consulta "Estrategias de resguardo") como la fuente de información para "la vista activa cambió", no las coordenadas del evento de desplazamiento. La confirmación de ajuste es el único evento que se activa de forma coherente en las rutas de deslizamiento, clic, desplazamiento programático ypopstate. - SÍ aplica transformaciones a un elemento secundario del objetivo de ajuste, nunca al objetivo de ajuste en sí. Una transformación en el objetivo de ajuste se retroalimenta en la geometría de ajuste del contenedor de desplazamiento, y el desplazador tendrá un error en medio del gesto.
- SÍ aplica
inerta todas las vistas, excepto a la que está visible en ese momento. Sin esto, el foco y la navegación con lector de pantalla se filtran en las vistas ocultas detrás del paralaje, lo que es invisible para los usuarios videntes, pero representa una falla grave de accesibilidad. - SÍ envía una entrada del historial en cada desglose y controla
popstatepara que funcionen el gesto Atrás a nivel del SO y los botones Atrás/Adelante del navegador. Esto es lo que hace que el patrón se sienta como una aplicación nativa. - SÍ concilia el historial desde el controlador de cambio de vista activa cuando un deslizamiento hacia atrás llega a una vista cuya profundidad no coincide con
currentDepth. Sin esto, un retroceso posterior del SO devuelve al usuario a un lugar inesperado porque el cursor del historial del navegador no está sincronizado con lo que se muestra en pantalla. - SÍ quita las vistas que el usuario deslizó del DOM. De lo contrario, una sesión de desglose larga puede acumular decenas de subárboles separados. La ruta de URL almacenada en caché en
entriesByDepthes suficiente para volver a compilar cualquier vista si la navegación hacia adelante vuelve a ella. - SÍ llama a
.focus({preventScroll: true})cuando restablezcas el enfoque dentro de la pila. El valor predeterminadopreventScroll: falsehace que el navegador desplace el objetivo de enfoque a la vista, lo que lucha contra el contenedor de ajuste y puede hacer que el usuario se detenga a mitad del ajuste. - SÍ conserva cmd/ctrl/clic central en los vínculos internos para que las URLs se puedan compartir y abrir en una pestaña nueva.
- SÍ usa
behavior: 'instant'para saltos de historial de varios pasos Y para transiciones de popstate hacia adelante en el espacio (toIdx > fromIdx). De lo contrario, los saltos de varios pasos activaríanscrollsnapchangeen cada ajuste intermedio y realizarían N rondas de trabajo inerte, de enfoque y de historial. Los popstates de avance suelen ser falsos: iOS Safari trata los deslizamientos de borde como avance del navegador, incluso conoverscroll-behavior-x: noneestablecido, y un intercambio instantáneo es mucho menos engañoso que animar un "desglose" que el usuario no inició. La ruta de desglose iniciada por el usuario (drillDown) no se ve afectada porque se desplaza directamente, no a través depopstate. - SÍ respeta
prefers-reduced-motion: declarascroll-behavior: smoothsolo dentro de@media (prefers-reduced-motion: no-preference)y llama ascrollTo/scrollByconbehavior: 'auto'(no'smooth') para que la preferencia a nivel del SO se aplique sin ramificaciones de JS por llamada. Si se codificabehavior: 'smooth'de forma rígida, se omite el parámetro de configuración del usuario. - SÍ renderices elementos
<a href>reales como activadores de desglose, no<button onclick>ni<div>. Los elementos de anclaje reales obtienen una vista previa de la URL cuando se coloca el cursor sobre ellos, se pueden compartir, se puede hacer clic en ellos con el botón central, tienen un rol de lector de pantalla y SEO de forma gratuita. - SÍ incluye un botón Atrás explícito en cada vista de desglose. El gesto de deslizamiento solo funciona en pantallas táctiles; los usuarios de teclado, puntero y computadoras de escritorio necesitan una affordance visible.
- NO llames a
history.pushStatedesde el controladorpopstate, ya que eso envía entradas nuevas mientras el usuario intenta volver atrás y rompe el botón Atrás del navegador. - NO controles el paralaje con un objeto de escucha de eventos
scrollcuando haya animaciones controladas por el desplazamiento disponibles. La ruta de CSS se ejecuta en el compositor; un objeto de escucha de desplazamiento de JS se ejecuta en el subproceso principal y descartará fotogramas de forma visible durante el gesto. - NO cambies las vistas que quitaste del DOM después de un deslizamiento hacia atrás. Trata
entriesByDepthcomo el registro canónico: una entrada reducida tieneview: nully se vuelve a compilar a pedido enupdateFromHistoryState.
Estrategias de resguardo
Las funciones que pueden requerir resguardos son scroll-snap-events y scroll-driven-animations, que tienen historias sólidas de resguardo o mejora progresiva, y son seguras para este caso de uso:
Eventos de ajuste de desplazamiento
El evento scrollsnapchange es la forma más limpia de detectar que "cambió la vista activa": un objeto de escucha en la pila, que se activa exactamente una vez por confirmación de ajuste. En los navegadores que no lo tienen, se puede usar un polyfill para lograr el mismo efecto con un IntersectionObserver que observe cada vista para obtener visibilidad completa dentro de la pila. El resguardo se envía a la misma función onActiveViewChanged que usa la ruta principal, por lo que toda la lógica de transición de estado permanece en un solo lugar.
// 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);
}
Animaciones basadas en el desplazamiento
El efecto de paralaje, atenuación o sombra controlado por el desplazamiento es una mejora progresiva que se aplica sobre el núcleo de navegación. La puerta de enlace de CSS @supports (animation-timeline: view()) (que se muestra en el paso 2) limita la animación a los navegadores compatibles. En cualquier otro lugar, las vistas simplemente se cortan entre las detenciones de ajuste sin transición. El componente es completamente funcional sin el paralaje: el ajuste, la sincronización del historial, la administración del enfoque y inert siguen funcionando.
Si se requiere un resguardo de paralaje para los objetivos de referencia más antiguos, adjunta un objeto de escucha scroll a la pila y escribe una propiedad personalizada de CSS que describa el progreso de cada vista a través del puerto de desplazamiento. Luego, controla transform y filter desde esa propiedad:
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})`;
}
});
}