Overzicht
Een stack-drilldown is een hiërarchisch navigatiepatroon dat veel voorkomt in mobiele apps. Als je een link activeert, wordt er een nieuwe weergave op volledig scherm boven op de vorige geplaatst. De content van de weergave wordt gedefinieerd door de app: een subpagina met instellingen, een gesprek in een feed, een map in een bestandsbrowser, een detailpagina in een galerij, enzovoort. De gebruiker keert terug door de huidige weergave naar rechts van het scherm te swipen of door op een terugknop te tikken. De browsergeschiedenis blijft gesynchroniseerd, zodat het teruggebaar op OS-niveau, deep links en vooruit/terug navigeren allemaal samenhangend werken.
In deze handleiding wordt de stack als volgt geïmplementeerd:
- Een horizontale CSS-container voor scrolluitlijning waarin elke weergave precies één uitlijningsstop is. Als je een niveau dieper gaat, wordt er een nieuwe weergave toegevoegd en wordt er soepel naartoe gescrold. Het terugveeggebaar wordt native afgehandeld door de browser, waardoor momentum, snelheid en onderbrekingstracking kosteloos worden geleverd zonder JavaScript voor aanwijzergebeurtenissen.
- Een
scrollsnapchange-gebeurtenislistener op de stapel die wordt geactiveerd als het uitlijndoel verandert. Dit wordt gebruikt als de enige bron van waarheid voor 'de actieve weergave is gewijzigd'. Swipen, klikken, programmatisch scrollen enpopstate-paden komen dus allemaal samen in één callback dieinertupdatet, de focus herstelt, weergaven verwijdert waar de gebruiker langs heeft geswipet en de browsergeschiedenis afstemt. - Een
pushState/popstate-integratie zodat elke uitsplitsing een geschiedenisitem toevoegt, het gebaar Terug op OS-niveau werkt en deep links rechtstreeks in de juiste weergave worden geopend. - Een (optionele) scrollgestuurde
view()-animatie in elke weergave die een parallax + dim + schaduweffect produceert dat rechtstreeks is gekoppeld aan het swipegebaar, zodat de zichtbare beweging wordt gestuurd door de vinger van de gebruiker, niet door een tween.
Deze aanpak heeft de voorkeur boven JavaScript-gestuurde transform-animaties, omdat het snapmechanisme de gebruiker directe controle geeft over de positie van het deelvenster (de vinger van de gebruiker stuurt het aan, niet een tween) en overeenkomt met de interactiepatronen die gebruikers verwachten van systeemeigen mobiele apps.
Implementatie
1. Opmaak
De statische HTML is slechts een lege stackcontainer. Weergaven worden gemaakt in JavaScript en toegevoegd terwijl de gebruiker navigeert.
<div class="Stack">
<!-- Intentionally empty. The initial view is appended by JavaScript
at init time (step 8). -->
</div>
Elke weergave is een .Stack-view direct onderliggend element van .Stack (het uitlijndoel) met een .Stack-viewContent-wrapper erin (waar de weergavecontent zich bevindt en waar de parallax-transformatie van toepassing is, zie stap 2). De stapel heeft op elk moment één weergave per actief geschiedenisitem, van links naar rechts in de volgorde van de uitsplitsing. Nadat de gebruiker 2 niveaus heeft ingezoomd vanaf de root, ziet de gerenderde DOM er zo uit:
<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>
Opmerkingen:
- Alle weergaven behalve de weergave die op dit moment zichtbaar is, hebben het kenmerk
inert. Dit wordt automatisch toegepast/verwijderd door descrollsnapchange-handler in stap 7. Stel dit niet in via je weergavebouwers. - Weergaven waar de gebruiker langs swipet, worden verwijderd uit de DOM (ook door stap 7), zodat de stack nooit groter wordt dan
currentDepth + 1onderliggende elementen. Ze worden op verzoek opnieuw opgebouwd op basis van hun URL-paden in het cachegeheugen als de navigatie vooruit naar ze terugkeert.
2. Stijlen
De stapelscroller
De stapel is een horizontaal raster waarin elke kinderweergave precies de breedte van de container heeft, waarbij CSS-scrolluitlijning één weergave per uitlijning afdwingt. Hierdoor voelt het gebaar voor terugswipen natuurlijk aan.
.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;
}
Het stapeleffect (parallax/dimmen/schaduw)
Een scrollgestuurde view(inline)-animatie houdt de voortgang van elke weergave bij via de stapelscroller en past een parallax + dim toe op de weergave die wordt afgesloten, plus een schaduw op inkomende gedetailleerde weergaven, zodat ze worden gelezen als 'kaarten' die over de vorige weergave worden gestapeld.
/* 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; }
}
}
OPMERKING: Dit effect is populair in moderne native stack-apps en is een goed startpunt, maar de exacte visuele effecten kunnen zo nodig worden aangepast aan bestaande overgangsstijlen.
3. Modulestatus
De stack houdt 4 statusitems bij in het modulebereik:
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 3 app-specifieke helpers, de enige plekken waar de routing en weergave van je app worden ingevoegd:
// 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. Details
Een drilldown doet 4 dingen in deze volgorde: een geschiedenisitem pushen, de nieuwe weergave maken, deze toevoegen en er vloeiend naartoe scrollen. De scrollsnapchange-handler (stap 7) neemt het vanaf daar over zodra de snap is voltooid.
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. Afhandeling van klikken en de terugknop
Onderschep linkklikken in de stack en zet ze om in uitsplitsingen. Behoud het gedrag van de modificatietoets, zodat cmd/middenklik de link nog steeds opent in een nieuw tabblad.
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. Synchroniseren vanuit geschiedenis (popstate)
popstate wordt geactiveerd als de gebruiker de knop Terug of Vooruit van de browser/het OS gebruikt of als JavaScript history.back() / .go() aanroept. Deze handler is het enige pad dat de stack scrollt als reactie op een wijziging in de geschiedenis.
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: de enige bron van waarheid
Na elke snap-commit (of deze nu wordt geactiveerd door een swipe, een klik of een programmatische scroll) activeert de browser een scrollsnapchange-gebeurtenis in de scrollcontainer, waarbij het nieuwe gesnapte element wordt weergegeven als event.snapTargetInline (voor horizontaal snappen). Door alle statusovergangen in deze ene handler te plaatsen, blijven het swipepad, het klikpad en het popstate-pad coherent.
De handler wordt geëxtraheerd naar een zelfstandige onActiveViewChanged-functie, zodat de fallback (zie Fallback-strategieën hieronder) deze opnieuw kan gebruiken zonder de logica te dupliceren.
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. Initialisatie (waaronder deep links)
Als de pagina wordt geladen, verwijst de URL misschien al naar een diepe weergave (een gedeelde link, een bookmark, een vernieuwing op een diepere pagina). Maak de eerste weergave die overeenkomt met de URL (root of deep-linked, maar nooit beide), voeg deze toe aan de lege stack, voer de geschiedenisvermelding met diepte 0 in en voer een eerste scroll-pass uit met behavior: 'instant' zodat de parallax niet animeert bij de eerste weergave.
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');
Praktische tips
- WEL: Gebruik de gebeurtenis
scrollsnapchange(met eenIntersectionObserver-fallback, zie Fallback-strategieën) als de bron van de waarheid voor 'de actieve weergave is gewijzigd', niet de coördinaten van de scrollgebeurtenis. Snap commit is de enige gebeurtenis die consistent wordt geactiveerd voor de paden voor swipen, klikken, programmatic scrollen enpopstate. - Pas transformaties WEL toe op een onderliggend element van het uitlijndoel, nooit op het uitlijndoel zelf. Een transformatie op het uitlijndoel wordt teruggevoerd naar de uitlijnmeetkunde van de scrollcontainer en de scroller hapert halverwege het gebaar.
- Pas
inertWEL toe op elke weergave, behalve de weergave die op dit moment zichtbaar is. Zonder dit lekken focus en schermlezernavigatie naar weergaven die verborgen zijn achter de parallax. Deze zijn onzichtbaar voor gebruikers die kunnen zien, maar vormen een ernstige toegankelijkheidsfout. - WEL: Push een geschiedenisitem bij elke uitsplitsing en verwerk
popstatezodat het teruggebaar op OS-niveau en de knoppen Vorige/Volgende van de browser werken. Hierdoor voelt het patroon aan als een systeemeigen app. - Zorg dat de geschiedenis wordt afgestemd vanuit de handler active-view-changed als een swipe-back op een weergave terechtkomt waarvan de diepte niet overeenkomt met
currentDepth. Zonder dit brengt een volgende OS Terug de gebruiker naar een onverwachte plek, omdat de geschiedeniscursor van de browser niet synchroon loopt met wat er op het scherm staat. - WEL: Verwijder weergaven waar de gebruiker voorbij heeft geswipet uit de DOM. Een lange drilldown-sessie kan anders tientallen losgekoppelde substructuren verzamelen. Het gecachte URL-pad in
entriesByDepthis voldoende om een weergave opnieuw op te bouwen als de navigatie vooruit ernaar terugkeert. - WEL
.focus({preventScroll: true})aanroepen als je de focus herstelt in de stack. De standaardpreventScroll: falsezorgt ervoor dat de browser het focusdoel in beeld scrollt. Dit gaat in tegen de snapcontainer en kan de gebruiker halverwege de snap laten landen. - WEL: Behoud cmd/ctrl/middenklik op interne links, zodat URL's deelbaar blijven en kunnen worden geopend in een nieuw tabblad.
- Gebruik WEL
behavior: 'instant'voor geschiedenissprongen met meerdere stappen EN voor ruimtelijke voorwaartse popstate-overgangen (toIdx > fromIdx). Sprongen met meerdere stappen activeren andersscrollsnapchangebij elke tussenliggende snap en voeren N rondes van inert/focus/geschiedeniswerk uit. Voorwaartse popstates zijn vaak onjuist. iOS Safari behandelt vegen vanaf de rand als vooruitgaan in de browser, zelfs alsoverscroll-behavior-x: noneis ingesteld. Een onmiddellijke wissel is veel minder misleidend dan een animatie van een drilldown die de gebruiker niet heeft gestart. Het door de gebruiker geïnitieerde drilldown-pad (drillDown) wordt niet beïnvloed, omdat het rechtstreeks scrollt, niet viapopstate. - 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. Als jebehavior: 'smooth'hardcodeert, wordt de instelling van de gebruiker omzeild. - WEL: Geef echte
<a href>-elementen weer als triggers voor een drilldown, niet<button onclick>of<div>. Echte ankers krijgen kosteloos een URL-voorbeeld bij de muisaanwijzer, deelbaarheid, middenklik, schermlezerrol en SEO. - WEL: Voeg een expliciete terugknop toe aan elke gedetailleerde weergave. Het swipegebaar werkt alleen op aanraakschermen. Gebruikers van een toetsenbord, muis of desktop hebben een zichtbare affordance nodig.
- Roep
history.pushStateNIET aan vanuit depopstate-handler. Dat pusht nieuwe vermeldingen terwijl de gebruiker probeert terug te gaan en de terugknop van de browser niet meer werkt. - Stuur de parallax NIET aan met een
scroll-gebeurtenislistener als scrollgestuurde animaties beschikbaar zijn. Het CSS-pad wordt uitgevoerd op de compositor. Een JS-scroll-listener wordt uitgevoerd op de hoofdthread en laat zichtbaar frames vallen tijdens het gebaar. - Mutateer GEEN weergaven die je uit de DOM hebt verwijderd na een swipe-back. Behandel
entriesByDepthals de canonieke record: een ingekorte invoer heeftview: nullen wordt op verzoek opnieuw opgebouwd inupdateFromHistoryState.
Reservestrategieën
De functies waarvoor mogelijk fallbacks nodig zijn, zijn scroll-snap-events en scroll-driven-animations. Beide hebben robuuste fallback- of progressive enhancement-verhalen en kunnen veilig worden gebruikt voor deze use case:
Gebeurtenissen voor scrolluitlijning
De gebeurtenis scrollsnapchange is de duidelijkste manier om te detecteren dat de actieve weergave is gewijzigd: één listener op de stack, die precies één keer per snap-commit wordt geactiveerd. In browsers zonder deze functie kan hetzelfde effect worden bereikt met een IntersectionObserver die elke weergave controleert op volledige zichtbaarheid in de stack. De fallback wordt verzonden naar dezelfde onActiveViewChanged-functie die het primaire pad gebruikt, zodat alle logica voor de statusovergang op één plek blijft.
// 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);
}
Scrollgestuurde animaties
Het scrollgestuurde parallax-/dim-/schaduweffect is een progressive enhancement bovenop de navigatiekern. De CSS @supports (animation-timeline: view())-poort (getoond in stap 2) beperkt de animatie tot ondersteunende browsers. Overal anders wisselen de weergaven gewoon tussen snapstops zonder overgang. De component is volledig functioneel zonder de parallax: vastzetten, geschiedenis synchroniseren, focusbeheer en inert werken nog steeds.
Als er een parallax-fallback vereist is voor oudere basislijndoelen, voeg je een scroll-listener toe aan de stack en schrijf je een aangepaste CSS-property die de voortgang van elke weergave via de scrollport beschrijft. Stuur daarna transform en filter aan vanuit die property:
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})`;
}
});
}