スタックのドリルダウン

概要

スタック ドリルダウンは、モバイルアプリで一般的な階層型ナビゲーション パターンです。リンクを有効にすると、前のビューの上に新しい全画面ビューがプッシュされます。ビューのコンテンツは、設定のサブページ、フィード内のスレッド、ファイル ブラウザ内のフォルダ、ギャラリー内の詳細ページなど、アプリで定義されます。ユーザーは、現在のビューを画面外の右側にスワイプするか、戻るボタンをタップして戻ります。ブラウザの履歴は同期されたままになるため、OS レベルの「戻る」ジェスチャー、ディープリンク、「進む」/「戻る」ナビゲーションはすべて一貫して機能します。

このガイドでは、スタックを次のように実装します。

  • 各ビューが 1 つのスナップ ストップとなる、水平方向の CSS スクロール スナップ コンテナ。ドリルダウンすると、新しいビューが追加され、スムーズにスクロールされます。スワイプバック ジェスチャーはブラウザによってネイティブに処理され、ポインタイベント JavaScript を使用せずに、勢い、速度、中断のトラッキングを無料で行うことができます。
  • スナップ ターゲットが変更されたときに呼び出される、スタック上の scrollsnapchange イベント リスナー。これは、「アクティブ ビューが変更された」という信頼できる唯一の情報源として使用されます。そのため、スワイプ、クリック、プログラムによるスクロール、popstate のパスはすべて 1 つのコールバックに収束し、inert の更新、フォーカスの復元、ユーザーがスワイプしたビューの削除、閲覧履歴の調整が行われます。
  • pushState / popstate の統合により、ドリルダウンごとに履歴エントリが追加され、OS レベルの戻る操作が機能し、ディープリンクが適切なビューに直接開きます。
  • 各ビューの(オプションの)スクロール駆動型 view() アニメーション。スワイプ操作に直接関連付けられた視差 + 暗転 + 影の効果を生成します。これにより、表示されるモーションはトゥイーンではなく、ユーザーの指によって駆動されます。

このアプローチは、JavaScript による transform アニメーションよりも推奨されます。スナップ メカニズムにより、ユーザーはパネルの位置を直接ジェスチャーで制御でき(トゥイーンではなく指で操作)、ネイティブ モバイルアプリで想定される操作パターンに一致するためです。

実装

1. マークアップ

静的 HTML は空のスタック コンテナにすぎません。ビューは JavaScript で構築され、ユーザーが移動するたびに追加されます。

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

各ビューは .Stack(スナップ ターゲット)の .Stack-view 直接の子であり、内部に .Stack-viewContent ラッパーがあります(ビュー コンテンツが存在し、パララックス変換が適用される場所。ステップ 2 を参照)。スタックには、アクティブな履歴エントリごとに 1 つのビューが、ドリルダウン順に左から右に表示されます。ユーザーがルートから 2 つのレベルをドリルダウンすると、レンダリングされた DOM は次のようになります。

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

注:

  • 現在表示されているビューを除くすべてのビューに inert 属性が設定されています。これは、ステップ 7 の scrollsnapchange ハンドラによって自動的に適用 / 削除されます。ビュービルダーから設定しないでください。
  • ユーザーがスワイプして戻ったビューは DOM から削除されるため(ステップ 7 も同様)、スタックが currentDepth + 1 個の子を超えて増えることはありません。前方ナビゲーションで戻った場合は、キャッシュに保存された URL パスからオンデマンドで再構築されます。

2. スタイル

スタック スクロール

スタックは水平方向のグリッドで、各子ビューはコンテナの幅とまったく同じです。CSS のスクロール スナップにより、1 回のスナップにつき 1 つのビューが表示されます。これにより、スワイプバック ジェスチャーがネイティブな操作感になります。

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

「スタック」効果(視差 / 暗転 / シャドウ)

スクロール駆動の view(inline) アニメーションは、スタック スクロールでの各ビューの進行状況を追跡し、終了するビューにパララックス + ディムを適用します。また、ドリルダウン ビューにシャドウを適用して、前のビューに重なる「カード」として読み取れるようにします。

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

注: このエフェクトは、最新のネイティブ スタック アプリケーションでよく使用されており、出発点として適していますが、必要に応じて既存の遷移スタイルに合わせてカスタマイズすることもできます。

3. モジュールの状態

このスタックは、モジュール スコープで 4 つの状態を追跡します。

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;

さらに、アプリ固有の 3 つのヘルパー(アプリのルーティングとビューのレンダリングがプラグインされる唯一の場所):

// 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. ドリルダウン

ドリルダウンでは、履歴エントリの push、新しいビューの作成、追加、スムーズ スクロールという 4 つの処理がこの順序で行われます。スナップが落ち着くと、scrollsnapchange ハンドラ(ステップ 7)がそこから引き継ぎます。

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. クリックと [戻る] ボタンの処理

スタック内のリンククリックをインターセプトして、ドリルダウンに変換します。修飾キーの動作を保持して、cmd キーを押しながらクリックするか、中クリックすると、リンクが新しいタブで開くようにします。

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. 履歴から同期(popstate)

popstate は、ユーザーがブラウザまたは OS の [戻る] ボタンまたは [進む] ボタンを使用したとき、または JavaScript が history.back() / .go() を呼び出したときに呼び出されます。このハンドラは、履歴の変更に応じてスタックをスクロールする唯一のパスです。

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: 信頼できる唯一の情報源

スワイプ、クリック、プログラムによるスクロールのいずれによってトリガーされたかにかかわらず、スナップ コミットのたびに、ブラウザはスクロール コンテナで scrollsnapchange イベントを発生させ、新しくスナップされた要素を event.snapTargetInline(水平スナップの場合)として公開します。すべての状態遷移をこの 1 つのハンドラ内に配置することで、スワイプパス、クリックパス、popstate パスの一貫性が保たれます。

ハンドラはスタンドアロンの onActiveViewChanged 関数に抽出されるため、フォールバック(下記の「フォールバック戦略」を参照)はロジックを複製せずに再利用できます。

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

ページが読み込まれると、URL がすでにディープビュー(共有リンク、ブックマーク、ディープページの更新)を指している場合があります。URL に一致する初期ビュー(ルートまたはディープリンク。両方は不可)を構築し、空のスタックに追加して、深度 0 の履歴エントリをシードし、behavior: 'instant' を使用して初期スクロールパスを実行します。これにより、最初の描画でパララックスがアニメーション化されなくなります。

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

ベスト プラクティス

  • 推奨: スクロール イベントの座標ではなく、scrollsnapchange イベント(IntersectionObserver フォールバックを使用 - 「フォールバック戦略」を参照)を「アクティブなビューが変更された」ことの信頼できる情報源として使用します。スナップ コミットは、スワイプ、クリック、プログラムによるスクロール、popstate パスで一貫して発生する唯一のイベントです。
  • 変換は、スナップ ターゲット自体ではなく、スナップ ターゲットの子に適用してください。スナップ ターゲットの変換がスクロール コンテナのスナップ ジオメトリにフィードバックされ、スクローラーがジェスチャーの途中で不具合を起こします。
  • 現在表示されているビューを除くすべてのビューに inert を適用する。これがないと、フォーカスとスクリーン リーダーのナビゲーションがパララックスの背後に隠れたビューに漏れてしまいます。これは、視覚に障がいのないユーザーには見えませんが、アクセシビリティの重大な不具合です。
  • 推奨: ドリルダウンごとに履歴エントリを push し、popstate を処理して、OS レベルの戻る操作とブラウザの戻る / 進むボタンが機能するようにします。これにより、パターンがネイティブ アプリのように感じられます。
  • 推奨: スワイプバックが、currentDepth と一致しない深度のビューに到達した場合は、active-view-changed ハンドラから履歴を調整します。これがないと、ブラウザの履歴カーソルが画面上の内容と同期していないため、後続の OS の戻る操作でユーザーが予期しない場所に戻ってしまいます。
  • 推奨事項:ユーザーがスワイプして通り過ぎたビューを DOM から削除します。そうしないと、長いドリルダウン セッションで、デタッチされたサブツリーが数十個も蓄積される可能性があります。entriesByDepth にキャッシュに保存された URL パスは、前方ナビゲーションが戻った場合にビューを再構築するのに十分です。
  • スタック内でフォーカスを復元する場合は、.focus({preventScroll: true}) を呼び出す。デフォルトの preventScroll: false では、ブラウザがフォーカス ターゲットをスクロールして表示します。これはスナップ コンテナと競合し、ユーザーがスナップの途中で止まる可能性があります。
  • 推奨: 内部リンクで cmd / ctrl / 中クリックを保持して、URL を共有可能にし、新しいタブで開けるようにします。
  • 複数ステップの履歴ジャンプと空間前方 popstate 遷移(toIdx > fromIdx)には behavior: 'instant' を使用します。複数ステップのジャンプでは、中間スナップごとに scrollsnapchange が発生し、不活性 / フォーカス / 履歴の処理が N 回繰り返されます。進む popstate は、多くの場合、誤ったものです。iOS Safari では、overscroll-behavior-x: none が設定されていても、エッジスワイプがブラウザの進む操作として扱われます。ユーザーが開始していない「ドリルダウン」をアニメーション化するよりも、即座にスワップする方が誤解を招く可能性がはるかに低くなります。ユーザーが開始したドリルダウン パス(drillDown)は、popstate 経由ではなく直接スクロールするため、影響を受けません。
  • 推奨事項: prefers-reduced-motion を尊重します。scroll-behavior: smooth は @media (prefers-reduced-motion: no-preference) 内でのみ宣言し、scrollTo / scrollBy は behavior: 'auto'('smooth' ではなく)で呼び出します。これにより、呼び出しごとの JS 分岐なしで OS レベルの設定が有効になります。behavior: 'smooth' をハードコードすると、ユーザーの設定がバイパスされます。
  • <button onclick> や <div> ではなく、実際の <a href> 要素をドリルダウン トリガーとしてレンダリングしてください。実際のアンカーでは、ホバー時の URL プレビュー、共有可能性、ミドルクリック、スクリーン リーダーの役割、SEO を無料で利用できます。
  • 推奨事項: すべてのドリルダウン ビューに明示的な戻るボタンを含めます。スワイプ操作はタッチでのみ機能します。キーボード、ポインタ、デスクトップのユーザーには、目に見えるアフォーダンスが必要です。
  • popstate ハンドラから history.pushState を呼び出さないでください。ユーザーが戻ろうとしているときに新しいエントリがプッシュされ、ブラウザの戻るボタンが機能しなくなります。
  • スクロール駆動アニメーションが利用可能な場合は、scroll イベント リスナーでパララックスを駆動しないでください。CSS パスはコンポジタで実行されます。JS スクロール リスナーはメインスレッドで実行され、ジェスチャー中にフレームが目に見えてドロップします。
  • スワイプバック後に DOM から削除したビューを変更しないでください。entriesByDepth を正規レコードとして扱います。プルーニングされたエントリには view: null があり、updateFromHistoryState でオンデマンドで再ビルドされます。

代替戦略

フォールバックが必要になる可能性がある機能は、scroll-snap-events と scroll-driven-animations です。どちらも堅牢なフォールバックまたはプログレッシブ エンハンスメントのストーリーがあり、このユースケースで安全に使用できます。

スクロール スナップ イベント

scrollsnapchange イベントは、「アクティブ ビューが変更された」ことを検出する最もクリーンな方法です。スタックに 1 つのリスナーがあり、スナップ commit ごとに 1 回だけ呼び出されます。この機能がないブラウザでは、IntersectionObserver を使用して各ビューを監視し、スタック内の完全な可視性を確保することで、同じ効果をポリフィルできます。フォールバックは、プライマリ パスが使用するのと同じ onActiveViewChanged 関数にディスパッチされるため、すべての状態遷移ロジックが 1 か所に保持されます。

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

スクロールドリブン アニメーション

スクロール駆動の視差 / 暗転 / シャドウ効果は、ナビゲーション コアの上にプログレッシブ エンハンスメントとして追加されています。CSS の @supports (animation-timeline: view()) ゲート(ステップ 2 を参照)により、アニメーションは対応ブラウザに限定されます。それ以外のブラウザでは、ビューはトランジションなしでスナップ位置の間で切り替わります。このコンポーネントは、パララックスがなくても完全に機能します。スナップ、履歴の同期、フォーカス管理、inert はすべて引き続き機能します。

古いベースライン ターゲットに視差フォールバックが必要な場合は、scroll リスナーをスタックにアタッチし、スクロールポートを通過する各ビューの進行状況を記述する CSS カスタム プロパティを記述して、そのプロパティから transform と filter を駆動します。

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