Stack-Aufschlüsselung

Übersicht

Ein Stack-Drilldown ist ein hierarchisches Navigationsmuster, das in mobilen Apps häufig verwendet wird. Wenn ein Link aktiviert wird, wird eine neue Vollbildansicht über der vorherigen eingeblendet. Der Inhalt der Ansicht wird von der Anwendung definiert, z. B. eine Unterseite mit Einstellungen, ein Thread in einem Feed, ein Ordner in einem Dateibrowser oder eine Detailseite in einer Galerie. Der Nutzer kehrt zurück, indem er die aktuelle Ansicht nach rechts aus dem Bildschirm wischt oder auf den Button „Zurück“ tippt. Der Browserverlauf wird synchronisiert, sodass die Geste „Zurück“ auf Betriebssystemebene, Deeplinks und die Vorwärts-/Rückwärtsnavigation alle einheitlich funktionieren.

In diesem Leitfaden wird der Stack so implementiert:

  • Ein horizontaler CSS-Scroll-Snap-Container, in dem jede Ansicht genau ein Snap-Stop ist. Beim Aufschlüsseln wird eine neue Ansicht angehängt und es wird mit optimiertem Scrollen dorthin gescrollt. Die Wischgeste zum Zurückgehen wird nativ vom Browser verarbeitet, sodass Schwung, Geschwindigkeit und Unterbrechungen ohne JavaScript für Zeigerereignisse erfasst werden.
  • Ein scrollsnapchange-Ereignis-Listener im Stack, der ausgelöst wird, wenn sich das Einrastziel ändert. Dies ist die einzige verlässliche Quelle für „Die aktive Ansicht hat sich geändert“. Wisch-, Klick-, programmatische Scroll- und popstate-Pfade laufen also in einem einzigen Callback zusammen, der inert aktualisiert, den Fokus wiederherstellt, Ansichten entfernt, an denen der Nutzer vorbeigewischt hat, und den Browserverlauf abgleicht.
  • Eine Integration von pushState und popstate, sodass bei jedem Drilldown ein Verlaufseintrag hinzugefügt wird, die Zurück-Geste auf Betriebssystemebene funktioniert und Deeplinks direkt in der richtigen Ansicht geöffnet werden.
  • Eine (optionale) scrollgesteuerte view()-Animation für jede Ansicht, die einen Parallaxen-, Dimm- und Schatteneffekt erzeugt, der direkt mit der Wischgeste verknüpft ist. Die sichtbare Bewegung wird also durch den Finger des Nutzers und nicht durch ein Tween gesteuert.

Dieser Ansatz ist JavaScript-gesteuerten transform-Animationen vorzuziehen, da der Einrastmechanismus dem Nutzer die direkte Steuerung der Position des Bereichs per Geste ermöglicht (der Finger steuert die Position, nicht ein Tween) und den Interaktionsmustern entspricht, die Nutzer von nativen mobilen Apps erwarten.

Implementierung

1. Markieren & Zeichnen

Das statische HTML ist nur ein leerer Stack-Container. Ansichten werden in JavaScript erstellt und angehängt, wenn der Nutzer navigiert.

<div class="Stack">
  <!-- Intentionally empty. The initial view is appended by JavaScript
       at init time (step 8). -->
</div>

Jede Ansicht ist ein direktes untergeordnetes .Stack-view-Element von .Stack (dem Einrastziel) mit einem .Stack-viewContent-Wrapper darin (wo sich der Ansichtsinhalt befindet und wo die Parallax-Transformation angewendet wird – siehe Schritt 2). Der Stack hat zu jedem Zeitpunkt eine Ansicht pro aktivem Verlaufseintrag, von links nach rechts in der Drilldown-Reihenfolge. Nachdem der Nutzer zwei Ebenen vom Stamm aus aufgeschlüsselt hat, sieht das gerenderte DOM so aus:

<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>

Hinweise:

  • Alle Ansichten außer der aktuell sichtbaren haben das Attribut inert. Dies wird automatisch vom scrollsnapchange-Handler in Schritt 7 angewendet/entfernt. Legen Sie es nicht in Ihren View Buildern fest.
  • Ansichten, die der Nutzer zurückwischt, werden aus dem DOM entfernt (auch durch Schritt 7), sodass der Stack nie mehr als currentDepth + 1 untergeordnete Elemente enthält. Sie werden bei Bedarf aus ihren im Cache gespeicherten URL-Pfaden neu erstellt, wenn die Vorwärtsnavigation zu ihnen zurückkehrt.

2. Stile

Stack-Scroller

Der Stack ist ein horizontales Raster, in dem jede untergeordnete Ansicht genau die Breite des Containers hat. Mit CSS Scroll Snap wird eine Ansicht pro Snap erzwungen. Dadurch fühlt sich die Geste zum Zurückwischen natürlich an.

.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;
}

Der „Stapel“-Effekt (Parallax/Dim/Schatten)

Eine scrollgesteuerte view(inline)-Animation verfolgt den Fortschritt jeder Ansicht im Stack-Scroller und wendet einen Parallaxen- und Dimmeffekt auf die ausgehende Ansicht an. Außerdem wird ein Schatten auf eingehende Drilldown-Ansichten angewendet, damit sie als „Karten“ gelesen werden, die sich über der vorherigen Ansicht stapeln.

/* 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; }
  }
}

HINWEIS: Dieser Effekt ist in modernen nativen Stack-Anwendungen beliebt und ein guter Ausgangspunkt. Die genauen visuellen Effekte können jedoch bei Bedarf an vorhandene Übergangsstile angepasst werden.

3. Modulstatus

Der Stack verfolgt vier Status im Modulbereich:

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;

Außerdem drei anwendungsspezifische Hilfsprogramme – die einzigen Stellen, an denen das Routing und das View-Rendering Ihrer Anwendung eingebunden werden:

// 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. Aufschlüsseln

Bei einem Drilldown werden vier Dinge in dieser Reihenfolge ausgeführt: Ein Verlaufseintrag wird hinzugefügt, die neue Ansicht wird erstellt, sie wird angehängt und es wird mit optimiertem Scrollen dorthin gescrollt. Der scrollsnapchange-Handler (Schritt 7) übernimmt dann, sobald das Einrasten abgeschlossen ist.

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. Klick- und Zurück-Button-Verarbeitung

Linkklicks innerhalb des Stapels abfangen und in Aufschlüsselungen umwandeln. Das Verhalten der Modifikatortaste beibehalten, sodass der Link durch Klicken mit der Befehlstaste oder der mittleren Maustaste weiterhin in einem neuen Tab geöffnet wird.

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. Aus Verlauf synchronisieren (popstate)

popstate wird ausgelöst, wenn der Nutzer die Zurück- oder Vorwärts-Schaltfläche des Browsers/Betriebssystems verwendet oder wenn JavaScript history.back()/.go() aufruft. Dieser Handler ist der einzige Pfad, der den Stack als Reaktion auf eine Änderung des Verlaufs scrollt.

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: die zentrale Informationsquelle

Nach jedem Snap-Commit, egal ob durch Wischen, Klicken oder programmatisches Scrollen ausgelöst, löst der Browser ein scrollsnapchange-Ereignis im Scrollcontainer aus. Das neu eingerastete Element wird als event.snapTargetInline (für horizontales Einrasten) angezeigt. Wenn Sie alle Zustandsübergänge in diesen einen Handler einfügen, bleiben der Wischpfad, der Klickpfad und der popstate-Pfad kohärent.

Der Handler wird in eine eigenständige onActiveViewChanged-Funktion extrahiert, damit der Fallback (siehe „Fallback-Strategien“ unten) ihn wiederverwenden kann, ohne die Logik zu duplizieren.

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);
});

Beim Seitenaufbau verweist die URL möglicherweise bereits auf eine Unterseite (ein geteilter Link, ein Lesezeichen, eine Aktualisierung auf einer Unterseite). Erstellen Sie die erste Ansicht, die der URL entspricht – Stamm- oder Deeplink, aber nie beides –, hängen Sie sie an den leeren Stack an, fügen Sie den Verlaufseintrag mit Tiefe 0 hinzu und führen Sie einen ersten Scrollvorgang mit behavior: 'instant' aus, damit der Parallaxeneffekt beim ersten Rendern nicht animiert wird.

const initialUrlPath = getCurrentUrlPath();
const initialRouteData = resolveUrl(initialUrlPath);

// Build the initial view: a drill-down view if the URL maps to one,
// otherwise the root view. Whichever it is, that's the only view in
// the stack right now — the other will be created lazily by
// synthesizeRootEntry (step 5) or drillDown (step 4) if the user
// navigates to it.
let initialView;
if (initialRouteData) {
  initialView = createDrillDownView(initialRouteData);
} else {
  rootView = createRootView();
  initialView = rootView;
}
stack.appendChild(initialView);

entriesByDepth.set(0, {urlPath: initialUrlPath, view: initialView});
// replaceState attaches a `depth` to the entry the user landed on, so
// any subsequent pushState / popstate has a base depth to count from.
history.replaceState({depth: 0}, '');

updateFromHistoryState(history.state, 'instant');

Best Practices

  • DO: Verwenden Sie das Ereignis scrollsnapchange (mit einem IntersectionObserver-Fallback – siehe „Fallback-Strategien“) als „Source of Truth“ für „Die aktive Ansicht hat sich geändert“, nicht die Scroll-Ereigniskoordinaten. Das Snap-Commit-Ereignis ist das einzige Ereignis, das bei allen Pfaden (Wischen, Klicken, programmatisches Scrollen und popstate) konsistent ausgelöst wird.
  • DO apply transforms to a child of the snap target, never to the snap target itself. Eine Transformation des Einrastziels wird in die Einrastgeometrie des Scrollcontainers zurückgeführt und der Scroller wird mitten in der Geste fehlerhaft.
  • DO apply inert to every view except the currently visible one. Andernfalls dringen Fokus und Screenreader-Navigation in Ansichten ein, die hinter dem Parallax-Effekt verborgen sind. Das ist für sehende Nutzer unsichtbar, aber ein schwerwiegender Fehler bei der Barrierefreiheit.
  • DO push a history entry on every drill-down and handle popstate so the OS-level Back gesture and the browser back/forward buttons work. Dadurch fühlt sich das Muster wie eine systemeigene App an.
  • DO reconcile history from the active-view-changed handler when a swipe-back lands on a view whose depth doesn't match currentDepth. Andernfalls wird der Nutzer bei einem nachfolgenden Zurück-Befehl des Betriebssystems an eine unerwartete Stelle weitergeleitet, da der Verlaufscursor des Browsers nicht mit dem Inhalt auf dem Bildschirm synchronisiert ist.
  • RICHTIG: Ansichten, die der Nutzer übersprungen hat, aus dem DOM entfernen. Bei einer langen Aufschlüsselungssitzung können sich sonst Dutzende von getrennten Unterbäumen ansammeln. Der im Cache gespeicherte URL-Pfad in entriesByDepth reicht aus, um eine Ansicht neu zu erstellen, wenn die Vorwärtsnavigation dorthin zurückkehrt.
  • RUFEN SIE .focus({preventScroll: true}) auf, wenn Sie den Fokus innerhalb des Stapels wiederherstellen. Bei der Standardeinstellung preventScroll: false scrollt der Browser das Fokusziel in die Ansicht, was dem Snap-Container entgegenwirkt und dazu führen kann, dass der Nutzer mitten im Snap landet.
  • DO preserve cmd/ctrl/middle-click on internal links so URLs remain shareable and openable in a new tab.
  • DO use behavior: 'instant' for multi-step history jumps AND for spatial-forward popstate transitions (toIdx > fromIdx). Multi-step jumps would otherwise fire scrollsnapchange at every intermediate snap and do N rounds of inert/focus/history work. Vorwärts-Popstates sind oft fehlerhaft. iOS Safari behandelt Wischbewegungen am Rand als Vorwärtsbewegung im Browser, auch wenn overscroll-behavior-x: none festgelegt ist. Ein sofortiger Wechsel ist viel weniger irreführend als die Animation eines „Drilldowns“, den der Nutzer nicht initiiert hat. Der vom Nutzer initiierte Drilldown-Pfad (drillDown) ist davon nicht betroffen, da er direkt und nicht über popstate scrollt.
  • DO respect prefers-reduced-motion: declare scroll-behavior: smooth only inside @media (prefers-reduced-motion: no-preference) and call scrollTo / scrollBy with behavior: 'auto' (not 'smooth') so the OS-level preference takes effect without per-call JS branching. Wenn Sie behavior: 'smooth' hartcodieren, wird die Einstellung des Nutzers umgangen.
  • DO render real <a href> elements as drill-down triggers, not <button onclick> or <div>. Echte Anker bieten beim Bewegen des Mauszeigers eine URL-Vorschau, die Möglichkeit zum Teilen, die Möglichkeit zum Klicken mit der mittleren Maustaste, eine Screenreader-Rolle und SEO-Vorteile.
  • Fügen Sie in jeder Aufschlüsselungsansicht eine explizite Schaltfläche „Zurück“ ein. Die Wischgeste funktioniert nur auf Touchscreens. Nutzer, die eine Tastatur, einen Pointer oder einen Desktop-Computer verwenden, benötigen eine sichtbare Affordanz.
  • Rufen Sie history.pushState NICHT über den popstate-Handler auf. Dadurch werden neue Einträge hinzugefügt, während der Nutzer versucht, zurückzugehen, und der Button „Zurück“ des Browsers funktioniert nicht mehr.
  • Verwenden Sie KEINEN scroll-Event-Listener, um den Parallax-Effekt zu steuern, wenn scrollgesteuerte Animationen verfügbar sind. Der CSS-Pfad wird im Compositor ausgeführt. Ein JS-Scroll-Listener wird im Hauptthread ausgeführt und führt während der Geste zu sichtbaren Frame-Drops.
  • Ändern Sie keine Ansichten, die Sie nach einem Zurückwischen aus dem DOM entfernt haben. Behandeln Sie entriesByDepth als kanonischen Eintrag: Ein gekürzter Eintrag hat view: null und wird bei Bedarf in updateFromHistoryState neu erstellt.

Fallback-Strategien

Die Funktionen, für die möglicherweise Fallbacks erforderlich sind, sind Scroll-Snap-Ereignisse und scrollgesteuerte Animationen. Beide bieten robuste Fallback- oder progressive Verbesserungsoptionen und können für diesen Anwendungsfall sicher verwendet werden:

Scroll-Snap-Ereignisse

Das Ereignis scrollsnapchange ist die sauberste Methode, um zu erkennen, dass sich die Active View geändert hat. Es gibt einen Listener im Stack, der genau einmal pro Snap-Commit ausgelöst wird. In Browsern ohne diese Funktion kann derselbe Effekt mit einem IntersectionObserver erzielt werden, der jede Ansicht auf vollständige Sichtbarkeit im Stack überwacht. Der Fallback wird an dieselbe onActiveViewChanged-Funktion weitergeleitet, die auch der primäre Pfad verwendet. So bleibt die gesamte Logik für Zustandsübergänge an einem Ort.

// 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);
}

Scrollgesteuerte Animationen

Der scrollgesteuerte Parallax-/Dimm-/Schatteneffekt ist eine progressive Verbesserung der Navigation. Das CSS-@supports (animation-timeline: view())-Gate (siehe Schritt 2) beschränkt die Animation auf unterstützende Browser. In allen anderen Browsern wird einfach ohne Übergang zwischen den Snap-Stopps gewechselt. Die Komponente ist auch ohne Parallax-Effekt voll funktionsfähig – Einrasten, Synchronisierung des Verlaufs, Fokusverwaltung und inert funktionieren weiterhin.

Wenn für ältere Baseline-Ziele ein Parallax-Fallback erforderlich ist, hängen Sie einen scroll-Listener an den Stack an und schreiben Sie eine benutzerdefinierte CSS-Eigenschaft, die den Fortschritt jeder Ansicht im Scrollport beschreibt. Steuern Sie dann transform und filter über diese Eigenschaft:

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})`;
    }
  });
}