一貫したクロスドキュメント遷移

問題

クロスドキュメント ビュー遷移は、同一オリジンのナビゲーション中に 2 つのページ間で要素をアニメーション化します。ブラウザは、古いページのスクリーンショットをキャプチャして移動し、スクリーンショットから新しいページにアニメーション化します。新しいページで重要なリソース(スタイルシート、レイアウト スクリプト、主要な DOM 要素)の読み込みが完了していない場合、遷移は不完全な状態またはスタイルが適用されていない状態にアニメーション化されます。これにより、要素が誤った位置に変形する、アニメーションの途中でコンテンツがリフローする、遷移の完了後に代替フォントがウェブフォントにフラッシュするなど、視覚的な不具合が発生します。

ソリューション

新しいページの <head> 内の重要な <link> 要素と <script> 要素で blocking="render" を使用し、<link rel="expect"> を使用して、特定の DOM 要素が解析されるまでレンダリングをブロックします。これにより、新しいページの視覚的な状態が安定するまで、ブラウザはビュー遷移アニメーションを開始しません。ブラウザはバックグラウンドで HTML の解析を続行します。遅延されるのは描画のみです。

実装戦略

  1. 必須: 両方のページで @view-transition CSS at-rule を使用して、ドキュメント間のビュー遷移を有効にします。
  2. 必須: 重要なスタイルシートが <head> に含まれていることを確認します。<head> 内のスタイルシートは、デフォルトでレンダリングをブロックします。動的に挿入されるスタイルシートには、明示的な blocking="render" が必要です。
  3. 推奨: 遷移アニメーションの前に実行する必要がある <script> 要素(テーマを適用するスクリプトやレイアウトに影響するスクリプトなど)に blocking="render" を使用します。
  4. 推奨: <link rel="expect" href="#element-id" blocking="render"> を使用して、スクロールせずに見える範囲のコンテンツが解析されるまでレンダリングをブロックします。これは、すべての遷移タイプに適用されます。フルページのクロスフェード(空白ページへのアニメーションを回避するため)、モーフィング アニメーション(名前付き要素が DOM に存在することを確認するため)、スクリプト依存のレイアウト(スタイル設定されたコンテンツが解析されるようにするため)です。
  5. 重要でないコンテンツのレンダリングをブロックしないでください。最初のビューポートに影響するリソースと要素のみをブロックします。あまりに多くのコンテンツをブロックすると、遷移が遅れ、パフォーマンスが低下したように感じられます。

実装ガイド

ステップ 1: ドキュメント間のビュー遷移を有効にする

必須: ソースページとリンク先ページの両方に @view-transition at-rule を含める必要があります。これがないと、クロスドキュメント遷移は発生しません。

/*
  MANDATORY: Include this rule in every page that participates
  in cross-document view transitions.
  `navigation: auto` enables transitions for standard navigations
  (link clicks, form submissions, back/forward).
*/
@view-transition {
  navigation: auto;
}

/* MANDATORY Copy-Paste Safety: Disable cross-document view transitions for users requesting reduced motion */
@media (prefers-reduced-motion: reduce) {
  @view-transition {
    navigation: none;
  }
}

ステップ 2: 重要なスクリプトが実行されるまでレンダリングをブロックする

<head> 内の非ブロッキング スクリプトを、遷移アニメーションの前に実行する必要がある場合(テーマクラスを適用する場合や、レイアウトに影響を与える場合など)は、blocking="render" でマークします。これがないと、遷移がすでに開始された後に async、defer、type="module" スクリプトが実行される可能性があります。

<head>
  <!--
    DO: Mark layout-critical scripts with blocking="render".
  -->
  <script type=module blocking="render">
    // Example: apply a stored theme before the page renders,
    // so the transition snapshot reflects the correct theme.
    document.documentElement.dataset.theme =
      localStorage.getItem('theme') || 'light';
  </script>
</head>

ステップ 3: 主要な DOM 要素が解析されるまでレンダリングをブロックする

<head> 内のスタイルシートと blocking="render" スクリプトは、<head> が完全に処理されたことのみを保証します。<body> コンテンツが解析されるのを待つことはありません。追加のブロッキングがない場合、ブラウザは、スクロールせずに見える範囲の要素が DOM に存在する前に新しいページのスナップショットを取得する可能性があります。その結果、空白のページまたは部分的にレンダリングされたページにアニメーション化される遷移が発生します。

<link rel="expect"> は、特定の要素(id で識別される)が解析されるまでレンダリングをブロックすることで、この問題を解決します。href の値は、ターゲット要素の id 属性と一致するフラグメント識別子(例: #hero)にする必要があります。その要素の終了タグが解析されると、レンダリング ブロックは解除されます。<link rel="expect"> は、ターゲット DOM 要素がドキュメントに存在することを保証するだけで、外部サブリソース(<img> ピクセルなど)のダウンロードやデコードを待つことはありません。

次のすべてのシナリオで <link rel="expect"> を使用します。

ユースケース 1: ページ全体のクロスフェード

個々の要素に view-transition-name がない場合でも、デフォルトの root 遷移ではページ全体がクロスフェードされます。スクロールせずに見える範囲のコンテンツが解析される前に新しいページのスナップショットが作成されると、クロスフェードは古いページから空白または不完全なページにアニメーション化されます。スクロールせずに見える範囲のコンテンツの末尾を示す要素でレンダリングをブロックする。

<head>
  <link rel="stylesheet" href="/css/styles.css">

  <!--
    DO: Block rendering until the main content area is parsed,
    even for a simple cross-fade. Without this, the browser may
    snapshot the page before visible content exists in the DOM,
    causing the cross-fade to reveal a blank or partial page.
  -->
  <link rel="expect" href="#main-content" blocking="render">
</head>
<body>
  <header>...</header>
  <main id="main-content">
    <h1>Page Title</h1>
    <p>Above-the-fold content the user should see immediately.</p>
  </main>
  <!-- Content below the fold does NOT need to be blocked on -->
  <section>...</section>
</body>

ユースケース 2: 特定の要素間でモーフィング アニメーションを適用する

両方のページの要素が view-transition-name を共有している場合、ブラウザはナビゲーション全体で要素をスムーズにモーフィングします。遷移の開始時にターゲット要素が解析されていない場合、ブラウザはターゲット要素を見つけることができません。モーフィングは、個別の終了アニメーションと開始アニメーションに劣化します。view-transition-name を含む要素が解析されるまで、レンダリングをブロックします。

<link rel="expect"> は、ターゲット DOM 要素が解析されるまでレンダリングを一時停止するだけで、画像データのダウンロードやデコードを待機しないため、キャッシュに保存されていない <img> が新しいページのスナップショットで空の長方形としてキャプチャされることがあります。画像のモーフィング中に空白のスナップショットやレイアウト シフトが発生しないようにするには、重要な遷移画像をプリロードまたはプリデコードし(<link rel="preload" as="image"> を使用するか、リンク先ページを事前レンダリングするなど)、明示的なサイズ設定フォールバック(width 属性と height 属性、または CSS aspect-ratio、プレースホルダの背景色)を指定します。

<head>
  <link rel="stylesheet" href="/css/styles.css">

  <!--
    DO: Preload critical transition images so their pixels are
    available to decode before the new-page snapshot is captured.
    <link rel="expect"> only waits for the DOM element to be parsed,
    NOT for image pixels to download or decode.
  -->
  <link rel="preload" as="image" href="/img/product.webp" fetchpriority="high">

  <!--
    DO: Block rendering until the element participating in the
    morph animation has been parsed. Without this, the browser
    may start the transition before #hero exists, causing the
    morph to degrade to a fade-out/fade-in.
  -->
  <link rel="expect" href="#hero" blocking="render">

  <!--
    When multiple blocking="render" resources are present,
    rendering is blocked until ALL of them are satisfied.
    Here, the browser waits for both the script to execute
    AND the #hero element to be parsed — whichever comes last.
  -->
  <script async blocking="render" src="/js/transition-setup.js"></script>
</head>
<body>
  <header>...</header>
  <section id="hero">
    <h1 style="view-transition-name: page-title">Product Name</h1>
    <!--
      DO: Provide explicit dimensions (width/height or aspect-ratio)
      as a sizing fallback so the snapshot captures the correct layout
      box even if image decoding is still in progress.
    -->
    <img
      style="view-transition-name: hero-image"
      src="/img/product.webp"
      width="800"
      height="600"
      alt="Product"
    >
  </section>
</body>

ステップ 4: メディアクエリを使用してレスポンシブなレンダリング ブロックを行う

ビューポートのサイズによって、スクロールせずに見える範囲に表示されるコンテンツの量は異なります。<link rel="expect"> で media 属性を使用して、特定のビューポート幅で表示されるコンテンツのみのレンダリングをブロックします。

<head>
  <!--
    DO: Use media queries to conditionally block rendering.
    On wide screens, both the hero and the sidebar are visible,
    so block until both are parsed. On narrow screens, only the
    hero is visible initially.
  -->
  <link
    rel="expect"
    href="#hero"
    blocking="render"
    media="screen and (width <= 768px)"
  >
  <link
    rel="expect"
    href="#sidebar"
    blocking="render"
    media="screen and (width > 768px)"
  >
</head>

ステップ 5: コンテキスト依存の遷移に pagereveal を使用する(省略可)

pagereveal イベントは、コアのレンダリング ブロック戦略には必要ありません。これは、ユーザーがどこから移動したかに基づいて view-transition-name 値を動的に割り当てる必要がある場合にのみ必要です。たとえば、特定のリストアイテムを詳細ページのヘッダーに変換する場合などです。

view-transition-name 値が CSS で静的に割り当てられている場合、またはデフォルトのフルページ クロスフェードのみを使用している場合は、このステップを完全にスキップします。

<head>
  <!--
    MANDATORY: The pagereveal listener must be registered before
    the page renders. Inline small setup scripts with blocking="render"
    to avoid an extra network round-trip on cold loads, or use an
    external async script with blocking="render" so the listener is
    registered early without blocking HTML parsing.
    If the listener is registered too late (e.g., in a deferred
    script), the event may have already fired.
  -->
  <script async blocking="render" src="/js/transition-setup.js"></script>
</head>
// transition-setup.js
window.addEventListener('pagereveal', async (event) => {
  if (!event.viewTransition) return;

  const from = navigation.activation?.from;
  if (!from) return;

  const fromUrl = new URL(from.url);

  // DO: Assign view-transition-name based on navigation context.
  // This enables a morph animation from the product card on the
  // list page to the heading on the detail page.
  if (fromUrl.pathname === '/products/') {
    const heading = document.querySelector('main h1');
    if (!heading) return;

    heading.style.viewTransitionName = 'product-title';

    try {
      await event.viewTransition.ready;
    } catch (error) {
      // Skipped view transitions reject `ready` with InvalidStateError.
      if (error.name !== 'InvalidStateError') {
        throw error;
      }
    } finally {
      // MANDATORY: Remove the temporary name after the transition
      // finishes. Stale names interfere with subsequent navigations
      // and prevent the page from entering the bfcache.
      await event.viewTransition.finished;
      heading.style.viewTransitionName = '';
    }
  }
});

ベスト プラクティス

  • 可能な限り、CSS を介して view-transition-name を割り当ててください。名前がナビゲーション コンテキストに依存する場合は、JavaScript の割り当て(pagereveal を使用)を予約します。
  • 推奨事項: レンダリングをブロックするスクリプトを小さくして、高速化します。ブラウザには組み込みのタイムアウト(約 4 秒)があり、それを過ぎると遷移は完全にスキップされます(遷移が開始された場合は、InvalidStateError で viewTransition.ready が拒否されます)。
  • 最初のビューポートに表示されない、ページの下部にある要素をブロックするために <link rel="expect"> を使用しないでください。これにより、視覚的なメリットなしに遷移が遅延します。
  • アナリティクス、広告ネットワーク、タグ マネージャー、その他のサードパーティのローダーは、最初のビューポートにコンテンツを挿入する場合でも、blocking="render" でマークしないでください。ネットワーク レイテンシは遷移タイムアウトにカウントされ、出力はページの安定した状態の一部ではありません。
  • 同じページ上の複数の要素に同じ view-transition-name を割り当てないでください。名前が重複していると、移行全体がスキップされます。
  • 支援技術のタイミングへの影響: blocking="render" を使用すると、視覚的な更新と最初の描画が遅れます。これにより、視覚に障がいのないユーザーの視覚的な不具合は防止されますが、レンダリングされたユーザー補助ツリーに依存するスクリーン リーダーやその他の支援技術で、処理の遅延や初期化の遅延が発生する可能性があります。視覚的な連続性のメリットと、視覚に頼らないユーザーの初期読み取りレイテンシを比較検討し、レンダリングをブロックするスクリプトを最小限に抑え、最適化します。

代替戦略

クロスドキュメント ビュー遷移は、プログレッシブ エンハンスメントに最適です。サポートしていないブラウザでは、@view-transition ルールは無視され、この機能がない場合とまったく同じように標準の同一オリジン ナビゲーションが行われます。サポートされているブラウザではスムーズな遷移が実現され、それ以外のブラウザでは標準のナビゲーションが実現されます。ブラウザのサポートが限定的であることは、採用を避ける理由にはなりません。

クロスドキュメント ビュー遷移をサポートするすべてのブラウザは、blocking="render" と <link rel="expect"> もサポートしているため、このガイドで説明するレンダリング ブロック機能に個別のフォールバックは必要ありません。

その他の注意事項

  1. パフォーマンスへの影響: レンダリングをブロックするリソースは、ビュー遷移アニメーションの開始を遅らせます。レンダリングをブロックするスクリプトの数を最小限に抑え、スクロールせずに見える範囲の要素にのみ <link rel="expect"> を使用します。Speculation Rules API を使用してリンク先ページを事前レンダリングし、読み込みの遅延を完全に排除します。
  2. タイムアウト動作: レンダリングをブロックする時間の合計が約 4 秒を超えると、ブラウザは遷移をスキップします(アクティブな viewTransition.ready Promise は InvalidStateError で拒否されます)。重要なリソースがこの時間内に読み込まれるようにしてください。
  3. bfcache との互換性: 遷移後にクリーンアップされない一時的な view-transition-name の割り当てにより、ページが bfcache に入らない可能性があります。finished コールバックでは、動的に割り当てられた名前を必ず削除してください。