同じドキュメントの遷移

ウェブ機能: ビュー遷移

問題

ウェブサイトでは、多くの場合、オブジェクトの複数のビュー(商品のリストや各商品の詳細ページなど)が提供されます。2 つのビューを切り替える際に、つながりを感じられないことがよくあります。ユーザーが商品のサムネイルをクリックして詳細を表示すると、サムネイルが消え、画面の別の場所に新しい大きな画像がすぐに表示されます。この連続性の欠如により、ユーザーは要素間の関係を追跡しにくくなります。

ソリューション

View Transitions API を使用すると、遷移前と遷移後の異なる状態に存在する要素ペアを指定できます。シングルページ ナビゲーション(SPA)で document.startViewTransition() を使用して遷移をトリガーすると、ブラウザは共有の一意の view-transition-name によってこれらの共有要素を識別します。次に、位置、サイズ、スタイルの違いを自動的に計算し、古い状態から新しい状態にスムーズにアニメーション化します。この遷移は、z-index 値が高い要素よりも上の最上位レイヤで行われます。

実装ガイド

ステップ 1: 状態の変更を startViewTransition でラップする

シングルページ アプリケーション(SPA)または単純な状態変更の場合は、DOM を更新するロジックを document.startViewTransition でラップします。ブラウザは、現在の状態のスナップショットをキャプチャし、更新を実行してから、新しい状態をキャプチャします。

function navigate(view) {
  // MANDATORY: Wrap the update in startViewTransition
  document.startViewTransition(() => updateDOM(view));
}

ステップ 2: 共有遷移名を割り当てる

view-transition-name CSS プロパティを使用して、モーフィングする要素をブラウザに指示します。名前は任意です(none を除く)。必須: 指定された view-transition-name の前後に 1 つ以下の要素が必要です。指定された view-transition-name を持つ要素が 2 つ以上ある場合、DOM は遷移なしで直ちに新しい状態に更新されます。

複数の view-transition-name を使用して、複数の要素ペアをモーフィングできます。たとえば、商品画像とタイトルを別々の遷移で遷移させたい場合があります。

リスト表示には複数のアイテムがあるため、すべてのアイテムに同じ view-transition-name を指定することはできません。SPA では、この問題を 2 つの方法で解決できます。

  1. 動的な詳細ページ: リストページの各アイテムに一意の view-transition-name を割り当て、リストアイテムが選択されたときに、その名前を詳細ページの対応する要素に動的に適用します。

    /* In the list view, give each */
    #product-1 { view-transition-name: p1 }
    #product-2 { view-transition-name: p2 }
    #product-3 { view-transition-name: p3 }
    
    function updateDOM(clickedTransitionName){
      const hero = document.getElementById("hero");
      hero.style.viewTransitionName = clickedTransitionName;
    }
    
  2. 動的リストアイテム: 詳細ページの要素に view-transition-name を割り当て、その名前をリストページで選択されたアイテムに適用します。リストページに戻るときに、リストページのアイテムから view-transition-name を削除します。

詳細ページの #hero 要素と、リストページで選択された .thumbnail 要素は、view-transition-name を共有します。

#hero{
  view-transition-name: hero;
}
.thumbnail.selected {
  view-transition-name: hero;
}

サムネイルがクリックされたら、.selected クラス セレクタを使用して view-transition-name を割り当て、リスト表示を準備する必要があります。また、遷移を開始する前に DOM に変更を加える必要があります。

次に、document.startViewTransition() を呼び出して変更を適用し、ページを詳細ビューからリスト表示に移行します。

リスト表示に戻ったら、次のナビゲーションでエラーが発生しないように、ビュー遷移クラスをクリーンアップする必要があります。このクリーンアップは、遷移の finished promise が解決された後に実行できます。

// Function called when a thumbnail is clicked
function goFromListToDetail(e){
  e.currentTarget.classList.add("selected");
  const hero = document.getElementById("hero");
  const bgColor = getComputedStyle(e.currentTarget).backgroundColor;
  hero.style.background = bgColor;

  // Trigger the transition, checking for support
  if (!document.startViewTransition) {
    document.body.classList.add("detail");
    // MANDATORY Accessibility Routing: Route focus to the newly revealed heading to announce context and preserve logical tab flow
    document.getElementById("detail-heading")?.focus();
    return; // MANDATORY: End function execution if view transitions are not supported.  
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.add("detail");
  });
  // MANDATORY Accessibility Routing: Route focus after the view transition resolves
  transition.finished.finally(() => {
    document.getElementById("detail-heading")?.focus();
  });
}

// Function called when navigating from detail back to list view
function goFromDetailToList() {
  if (!document.startViewTransition) {
    document.body.classList.remove("detail");
    document.getElementById("list-heading")?.focus();
    return;
  }
  const transition = document.startViewTransition(() => {
    document.body.classList.remove("detail");
  });
  // Clean up the list view and route focus
  transition.finished.finally(() => {
    // Route focus back to list view
    document.getElementById("list-heading")?.focus();
    // Remove selected classList to remove view-transition-names
    document.querySelectorAll(".selected").forEach(
      (element) => {
        element.classList.remove("selected");
      },
    );
  });
}

どの方法を選択するかは、ユースケースによって異なります。動的リストアイテムでは、CSS の繰り返しは少なくなりますが、JavaScript の手動クリーンアップが増えます。

ステップ 3: アスペクト比の「引き伸ばし」を修正する

デフォルトでは、ブラウザは、両方に合わせて拡大されたグループ内で、古いスナップショットと新しいスナップショットをクロスフェードします。テキストを遷移させる場合は、遷移する要素のアスペクト比が安定するように、古いビューと新しいビューの両方でテキスト要素の幅を fit-content に設定します。

#list-page .title {
  width: fit-content;
}

#detail-page #title {
  width: fit-content;
}

アスペクト比が変化する要素を遷移させる場合は、古い疑似要素と新しい疑似要素の高さを ::view-transition-pair() 疑似要素の 100% に設定する必要があります。

::view-transition-old(hero),
::view-transition-new(hero){
  height: 100%;
}

疑似要素はライブ要素のスナップショットであるため、object-fit 宣言と object-position 宣言を使用して、遷移効果をより細かく制御することもできます。

ベスト プラクティス

  • 遷移を多く指定しすぎないでください。共有要素は、ユーザーが積極的に追跡しているプライマリ コンテンツ(ヒーロー画像、見出しなど)にのみ使用します。
  • 推奨: 移行が完了したら、一時的な view-transition-name 値を削除して、今後の移行に副作用を与えないようにします。
  • アクティブなアニメーションを含む要素を遷移させないでください。ビュートランジションはスナップショットに対して動作するため、ビュートランジション中はアニメーションが一時停止しているように見えます。
  • prefers-reduced-motion メディアクエリを使用して、動きを抑えるユーザー設定を尊重する。
  • 必須のアクセシビリティ ルーティング: ビュー遷移はページ レイアウトを動的に変更しますが、プログラムによるフォーカスは管理しません。遷移中に非表示または削除された要素にフォーカスが残っていると、フォーカスが失われ、キーボードや支援技術のユーザーにコンテキストが伝わらなくなります。DOM の更新直後、またはビュー遷移の finished Promise が解決されたときに、プログラムでフォーカスを更新されたページ見出しまたはビューコンテナに移動します(tabindex="-1" を使用)。
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

代替戦略

View Transitions API は、プログレッシブ エンハンスメントを目的として設計されています。サポートしていないブラウザでは、アニメーションなしで DOM の更新がすぐに実行されます。

function navigate(){
  if (!document.startViewTransition) {
    // Fallback: Just update the DOM
    updateDOM();
  } else {
    document.startViewTransition(() => updateDOM());
  }
}