Présentation
Une vue détaillée de la pile est un modèle de navigation hiérarchique, courant dans les applications mobiles, où l'activation d'un lien pousse une nouvelle vue plein écran au-dessus de la précédente. Le contenu de la vue est défini par l'application : une sous-page de paramètres, un fil de discussion dans un flux, un dossier dans un navigateur de fichiers, une page d'informations dans une galerie, etc. L'utilisateur revient en balayant la vue actuelle vers la droite ou en appuyant sur un bouton Retour. L'historique du navigateur reste synchronisé, de sorte que le geste de retour au niveau de l'OS, les liens profonds et la navigation avant/arrière fonctionnent tous de manière cohérente.
Ce guide implémente la pile comme suit :
- Un conteneur de défilement scroll-snap CSS horizontal où chaque vue correspond exactement à un point d'ancrage. L'exploration ajoute une nouvelle vue et permet de faire défiler la page de manière fluide. Le geste de balayage vers l'arrière est géré de manière native par le navigateur, ce qui permet de suivre l'élan, la vitesse et l'interruption sans JavaScript d'événement de pointeur.
- Un écouteur d'événements
scrollsnapchangesur la pile qui se déclenche lorsque la cible d'alignement change. Il s'agit de la source unique de vérité pour "la vue active a changé". Ainsi, les chemins de balayage, de clic, de défilement programmatique etpopstateconvergent tous vers un seul rappel qui met à jourinert, restaure la sélection, supprime les vues que l'utilisateur a balayées et rapproche l'historique du navigateur. - Une intégration
pushState/popstatepour que chaque exploration ajoute une entrée d'historique, que le geste de retour au niveau de l'OS fonctionne et que les liens profonds s'ouvrent directement dans la bonne vue. - Une animation
view()(facultative) basée sur le défilement sur chaque vue, qui produit un effet de parallaxe, d'assombrissement et d'ombre directement lié au geste de balayage. Ainsi, le mouvement visible est contrôlé par le doigt de l'utilisateur, et non par une interpolation.
Cette approche est préférable aux animations transform basées sur JavaScript, car le mécanisme d'alignement permet à l'utilisateur de contrôler directement la position du panneau par des gestes (son doigt le contrôle, et non un tween) et correspond aux modèles d'interaction que les utilisateurs attendent des applications mobiles natives.
Implémentation
1. Annoter
Le code HTML statique n'est qu'un conteneur de pile vide. Les vues sont créées en JavaScript et ajoutées au fur et à mesure que l'utilisateur navigue.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Chaque vue est un enfant direct .Stack-view de .Stack (la cible d'alignement) avec un wrapper .Stack-viewContent à l'intérieur (où se trouve le contenu de la vue et où la transformation de parallaxe s'applique ; voir l'étape 2). À tout moment, la pile comporte une vue par entrée d'historique active, de gauche à droite dans l'ordre d'exploration. Une fois que l'utilisateur a exploré deux niveaux à partir de la racine, le DOM affiché se présente comme suit :
<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>
Remarques :
- Toutes les vues, à l'exception de celle actuellement visible, portent l'attribut
inert. Il est appliqué/supprimé automatiquement par le gestionnairescrollsnapchangeà l'étape 7. Ne le définissez pas à partir de vos compilateurs de vues. - Les vues que l'utilisateur fait défiler vers l'arrière sont supprimées du DOM (également à l'étape 7), de sorte que la pile ne dépasse jamais
currentDepth + 1enfants. Elles sont recréées à la demande à partir de leurs chemins d'URL mis en cache si la navigation vers l'avant y revient.
2. Styles
Le défilement de la pile
La pile est une grille horizontale dans laquelle chaque vue enfant correspond exactement à la largeur du conteneur, avec un défilement scroll-snap CSS qui applique une vue par alignement. C'est ce qui donne au geste de balayage vers l'arrière son aspect natif.
.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'effet "pile" (parallaxe/assombrissement/ombre)
Une animation view(inline) basée sur le défilement suit la progression de chaque vue dans le défilement de la pile et applique un effet de parallaxe et d'assombrissement à la vue sortante, ainsi qu'une ombre sur les vues d'exploration entrantes afin qu'elles soient lues comme des "cartes" empilées sur la vue précédente.
/* 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; }
}
}
REMARQUE : Cet effet est populaire dans les applications de pile natives modernes et constitue un bon point de départ, mais les effets visuels exacts peuvent être personnalisés pour s'adapter aux styles de transition existants, si nécessaire.
3. État du module
La pile suit quatre éléments d'état dans le champ d'application du module :
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;
Plus trois assistants spécifiques à l'application, qui sont les seuls endroits où le routage et l'affichage de votre application sont connectés :
// 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. Afficher le détail
Une exploration effectue quatre actions dans cet ordre : elle ajoute une entrée d'historique, crée la nouvelle vue, l'ajoute et fait défiler la page de manière fluide jusqu'à elle. Le gestionnaire scrollsnapchange (étape 7) prend le relais une fois l'alignement terminé.
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. Gestion des clics et du bouton Retour
Interceptez les clics sur les liens dans la pile et convertissez-les en analyses détaillées. Conservez le comportement des touches de modification afin que la commande/le clic du milieu ouvre toujours le lien dans un nouvel onglet.
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. Synchroniser à partir de l'historique (popstate)
popstate se déclenche lorsque l'utilisateur utilise le bouton "Retour" ou "Suivant" du navigateur/de l'OS, ou lorsque JavaScript appelle history.back()/.go(). Ce gestionnaire est le seul chemin qui fait défiler la pile en réponse à une modification de l'historique.
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 : source unique de vérité
Après chaque validation d'alignement (déclenchée par un balayage, un clic ou un défilement programmatique), le navigateur déclenche un événement scrollsnapchange sur le conteneur de défilement, l'élément nouvellement aligné étant exposé en tant que event.snapTargetInline (pour l'alignement horizontal). En plaçant toutes les transitions d'état dans ce gestionnaire, vous assurez la cohérence du chemin de balayage, du chemin de clic et du chemin popstate.
Le gestionnaire est extrait dans une fonction onActiveViewChanged autonome afin que la stratégie de remplacement (voir "Stratégies de remplacement" ci-dessous) puisse le réutiliser sans dupliquer la logique.
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. Initialisation (y compris les liens profonds)
Lorsque la page se charge, l'URL peut déjà pointer vers une vue détaillée (un lien partagé, un favori, une actualisation sur une page en profondeur). Créez la vue initiale qui correspond à l'URL (racine ou lien profond, mais jamais les deux), ajoutez-la à la pile vide, amorcez l'entrée d'historique de profondeur 0 et exécutez un défilement initial avec behavior: 'instant' pour que la parallaxe ne s'anime pas lors du premier rendu.
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');
Bonnes pratiques
- À FAIRE : Utilisez l'événement
scrollsnapchange(avec une solution de remplacementIntersectionObserver, consultez "Stratégies de remplacement") comme source de vérité pour "la vue active a changé", et non les coordonnées de l'événement de défilement. La validation de l'alignement est le seul événement qui se déclenche de manière cohérente lors des balayages, des clics, du défilement programmatique et des cheminspopstate. - À FAIRE : Appliquez des transformations à un enfant de la cible d'alignement, jamais à la cible d'alignement elle-même. Une transformation sur la cible d'alignement est renvoyée dans la géométrie d'alignement du conteneur de défilement, et le défilement présente un bug au milieu du geste.
- À FAIRE : Appliquez
inertà toutes les vues, sauf à celle qui est actuellement visible. Sans cela, la navigation par sélection et par lecteur d'écran s'étend aux vues masquées derrière l'effet parallaxe, qui sont invisibles pour les utilisateurs voyants, mais qui constituent un problème d'accessibilité majeur. - À FAIRE : Transmettez une entrée d'historique à chaque exploration et gérez
popstatepour que le geste "Retour" au niveau de l'OS et les boutons "Précédent"/"Suivant" du navigateur fonctionnent. C'est ce qui donne l'impression d'une application native. - À FAIRE : Rapprochez l'historique du gestionnaire de changement de vue active lorsqu'un balayage vers l'arrière arrive sur une vue dont la profondeur ne correspond pas à
currentDepth. Sans cela, un retour arrière de l'OS renvoie l'utilisateur à un endroit inattendu, car le curseur de l'historique du navigateur n'est pas synchronisé avec ce qui s'affiche à l'écran. - À FAIRE : Supprimez du DOM les vues que l'utilisateur a balayées. Une longue session d'exploration peut sinon accumuler des dizaines de sous-arborescences dissociées. Le chemin d'URL mis en cache dans
entriesByDepthsuffit à recréer n'importe quelle vue si la navigation vers l'avant y revient. - À FAIRE : Appelez
.focus({preventScroll: true})lorsque vous restaurez le focus dans la pile. La valeur par défautpreventScroll: falsefait défiler la cible sélectionnée dans la vue, ce qui entre en conflit avec le conteneur d'alignement et peut placer l'utilisateur au milieu de l'alignement. - À FAIRE : Conservez les raccourcis Cmd/Ctrl/clic du milieu sur les liens internes afin que les URL restent partageables et puissent être ouvertes dans un nouvel onglet.
- À FAIRE : utilisez
behavior: 'instant'pour les sauts d'historique en plusieurs étapes ET pour les transitions popstate spatiales vers l'avant (toIdx > fromIdx). Sinon, les sauts en plusieurs étapes déclencheraientscrollsnapchangeà chaque instantané intermédiaire et effectueraient N cycles de travail inerte/focus/historique. Les popstates de transfert sont souvent fallacieux : iOS Safari traite les balayages de bord comme un transfert de navigateur même sioverscroll-behavior-x: noneest défini, et un échange instantané est beaucoup moins trompeur que l'animation d'un "approfondissement" que l'utilisateur n'a pas initié. Le chemin d'exploration initié par l'utilisateur (drillDown) n'est pas affecté, car il défile directement, et non viapopstate. - À FAIRE : Respectez
prefers-reduced-motion: déclarezscroll-behavior: smoothuniquement dans@media (prefers-reduced-motion: no-preference)et appelezscrollTo/scrollByavecbehavior: 'auto'(et non'smooth') pour que la préférence au niveau de l'OS soit prise en compte sans branchement JS par appel. Le codage en dur debehavior: 'smooth'contourne le paramètre de l'utilisateur. - À FAIRE : Utilisez de vrais éléments
<a href>comme déclencheurs d'exploration, et non des éléments<button onclick>ou<div>. Les ancres réelles bénéficient d'un aperçu de l'URL au survol, de la possibilité de partage, du clic du milieu, du rôle de lecteur d'écran et du SEO sans frais. - À FAIRE : Incluez un bouton Retour explicite dans chaque vue d'exploration. Le geste de balayage ne fonctionne que sur les écrans tactiles. Les utilisateurs de clavier, de pointeur et d'ordinateur de bureau ont besoin d'une affordance visible.
- N'appelez PAS
history.pushStateà partir du gestionnairepopstate, car cela ajoute de nouvelles entrées alors que l'utilisateur essaie de revenir en arrière, ce qui empêche le bouton "Retour" du navigateur de fonctionner. - NE PAS piloter l'effet parallaxe avec un écouteur d'événements
scrolllorsque les animations basées sur le défilement sont disponibles. Le chemin CSS s'exécute sur le compositeur, tandis qu'un écouteur de défilement JS s'exécute sur le thread principal et supprime visiblement des frames pendant le geste. - NE MODIFIEZ PAS les vues que vous avez supprimées du DOM après un balayage vers l'arrière. Traitez
entriesByDepthcomme l'enregistrement canonique : une entrée élaguée comporteview: nullet est recréée à la demande dansupdateFromHistoryState.
Stratégies de remplacement
Les fonctionnalités qui peuvent nécessiter des solutions de remplacement sont scroll-snap-events et scroll-driven-animations, qui disposent toutes deux de solutions de remplacement robustes ou d'histoires d'amélioration progressive, et qui peuvent être utilisées en toute sécurité pour ce cas d'utilisation :
Événements de défilement scroll-snap
L'événement scrollsnapchange est le moyen le plus propre de détecter que la vue active a changé : un écouteur sur la pile, déclenché exactement une fois par commit d'instantané. Dans les navigateurs qui n'en disposent pas, le même effet peut être polyfillé avec un IntersectionObserver qui surveille chaque vue pour une visibilité complète dans la pile. La création de remplacement est envoyée à la même fonction onActiveViewChanged que celle utilisée par le chemin d'accès principal. Ainsi, toute la logique de transition d'état reste au même endroit.
// 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);
}
Animations liées au défilement
L'effet de parallaxe/assombrissement/ombre déclenché par le défilement est une amélioration progressive qui s'ajoute à la navigation de base. La porte CSS @supports (animation-timeline: view()) (illustrée à l'étape 2) limite l'animation aux navigateurs compatibles. Partout ailleurs, les vues passent simplement d'un point d'ancrage à un autre sans transition. Le composant est entièrement fonctionnel sans la parallaxe : l'alignement, la synchronisation de l'historique, la gestion de la mise au point et inert fonctionnent toujours.
Si un remplacement de parallaxe est requis pour les anciennes cibles de référence, associez un écouteur scroll à la pile et écrivez une propriété CSS personnalisée décrivant la progression de chaque vue dans la fenêtre de défilement, puis pilotez transform et filter à partir de cette propriété :
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})`;
}
});
}