📚

View Transitions API と Navigation API でページ遷移アニメーションを実装してみる

2023/08/17に公開

Chrome 102 以降で Navigation API が、Chrome 111 以降で View Transitions API がサポートされました。 本記事ではこの二つの API を用いてページ遷移アニメーションを実装してみようと思います。

https://caniuse.com/mdn-api_navigation
https://caniuse.com/view-transitions

デモサイト&コード

本記事では以下のデモサイトとコードをもとに説明を行うことがあります。全体的な挙動やコードを確認したい際には以下をご参照ください。

https://vta-and-na.yend.dev/
https://github.com/yend724/view-transitions-api-and-navigation-api-demo

View Transitions API

View Transitions API は異なる DOM 状態間におけるアニメーション付きの画面遷移と DOM の更新を、従来よりもシンプルな方法で提供する API です。

View Transitions API の詳細な説明については View Transitions API - Web APIs | MDN をご参照ください。

デモページ&コード

本記事で紹介する View Transitions API のデモページ&コードは以下になります。

https://vta-and-na.yend.dev/view-transitions-api/
https://github.com/yend724/view-transitions-api-and-navigation-api-demo/tree/main/src/view-transitions-api

View Transitions API のデモ画面のキャプチャ

View Transitions APIのデモ画面

View Transitions API を用いたアニメーション実装

では早速コードを見ていきましょう。HTML の構造は次のようになっています。

HTML
<!-- 書籍一覧画面 -->
<main class="main" data-transition-wrapper="book-index">
  <h1 class="page-title">書籍一覧</h1>
  <div class="container">
    <div class="book-index-list">
      <article class="book">
        <a class="book-link link" href="/view-transitions-api/?page=book-detail">
          <img class="thumbnail" data-view-transition-name="thumbnail" src="..." />
          <h2 class="book-title" data-view-transition-name="title">吾輩は猫である</h2>
          <div class="book-author">夏目 漱石</div>
        </a>
      </article>
    </div>
  </div>
</main>
<!-- 書籍詳細画面(初期状態はhiddenで隠している) -->
<main class="main" data-transition-wrapper="book-detail" hidden>
  <h1 class="page-title">書籍詳細</h1>
  <div class="container">
    <article class="book">
      <h2 class="book-title" data-view-transition-name="title">吾輩は猫である</h2>
      <img class="thumbnail" data-view-transition-name="thumbnail" src="..."
      />
      <p class="book-content">吾輩わがはいは猫である。名前はまだ無い。...</p>
      <div class="book-author">夏目 漱石</div>
    </article>
    <a class="link back-link" href="./">一覧へ戻る</a>
  </div>
</main>

上記では書籍一覧画面と書籍詳細画面を一つの HTML ファイルに記述しています。初期状態では書籍詳細画面に hidden 属性を付与し、display:none で要素の表示を消しています。今回は、この二つの画面の表示を切り替えることで、擬似的にページ遷移を行なっていると考えてください。

次のコードは、画面遷移アニメーションを実現するための JavaScript コードです。

JavaScript
// startViewTransition の実行
const swap = async (from, to) => {
  return document.startViewTransition(() => {
    from.hidden = true;
    to.hidden = false;
  }).updateCallbackDone;
};

const findAnchorElement = element => {
  if (element.tagName === 'A') {
    return element;
  }
  return null;
};
const findParentAnchorElement = element => {
  return element.closest('a');
};

// 画面要素の取得
const bookIndexPage = document.querySelector(
  '*[data-transition-wrapper="book-index"]'
);
const bookDetailPage = document.querySelector(
  '*[data-transition-wrapper="book-detail"]'
);
const pageList = [bookIndexPage, bookDetailPage];

const documentBody = document.body;
documentBody.addEventListener('click', async e => {
  const target = e.target;

  // a要素(リンク)をクリックしたかの判別
  const anchorElement =
    findAnchorElement(target) || findParentAnchorElement(target);

  // a要素であれば画面遷移する
  if (anchorElement) {
    e.preventDefault();
    const url = new URL(anchorElement.href);
    const params = url.searchParams;
    const nextPage = params.get('page') ?? 'book-index';
    const nextSwapList =
      nextPage === 'book-index' ? pageList : [...pageList].reverse();
    await swap(...nextSwapList);
  }
});

肝となるのは swap() 関数です。swap() 関数の中で View Transitions API の document.startViewTransition(updateCallback) を呼び出しているのがわかります。

JavaScript
// document.startViewTransition の実行
const swap = async (from, to) => {
  return document.startViewTransition(() => {
    // DOM の更新処理
    // 書籍一覧画面と書籍詳細画面の切り替え
    from.hidden = true;
    to.hidden = false;
  }).updateCallbackDone;
};

startViewTransition(updateCallback)updateCallback には、DOM の更新処理を記述します。今回のデモでは、書籍一覧画面と書籍詳細画面の表示を切り替える処理を記述しています。

驚くべきことに、たったこれだけの記述でクロスフェードアニメーションを伴った DOM の更新が行われます。

JavaScript
// a要素をクリックした時に`swap()`を実行する
if (anchorElement) {
  e.preventDefault();
  const url = new URL(anchorElement.href);
  const params = url.searchParams;
  const nextPage = params.get('page') ?? 'book-index';
  const nextSwapList =
    nextPage === 'book-index' ? pageList : [...pageList].reverse();
  await swap(...nextSwapList);
}

後は a要素 をクリックした際に swap() を実行するだけです。書籍一覧画面と書籍詳細画面との間でアニメーション付きの画面遷移が行われます。

ここでデモページの挙動を観察してみると、画面全体のクロスフェード遷移(main要素 全体の遷移)と、異なるアニメーションを行っている要素が存在することに気付くと思います。

具体的に異なるアニメーションを行っている要素は次の二つになります。

<!-- 書籍タイトル -->
<h2 class="book-title" data-view-transition-name="title">吾輩は猫である</h2>

<!-- サムネイル -->
<img class="thumbnail" data-view-transition-name="thumbnail" src="..." />

上記の要素は画面遷移間で、要素自体が(クロスフェードでなく)移動もしくは拡大・縮小しているように見えます。これらの要素に対して適用されているスタイルを確認してみましょう。

CSS
*[data-view-transition-name] {
  contain: paint;
}
/* View Transitions API */
*[data-view-transition-name="title"] {
  view-transition-name: title;
}
*[data-view-transition-name="thumbnail"] {
  view-transition-name: thumbnail;
}

view-transition-name という見慣れないプロパティがあります。実はこのプロパティが、二つの要素が独立したアニメーションを行なっていた要因です。view-transition-name が遷移前と遷移後の両方にある場合、そのスタイルが当たった要素は独立した画面遷移となります。

画面遷移の流れ

ここで View Transitions API による画面遷移の流れについてざっくり確認してみます。

View Transitions API による画面遷移は、次の流れに沿って行われます。

  1. 開発者が document.startViewTransition(updateCallback) を実行する
  2. 「古い」状態として、現在の画面をキャプチャする
  3. レンダリングが一時停止さる
  4. updateCallback が実行される
  5. 「新しい」状態として、現在の画面をキャプチャする
  6. 遷移擬似要素(::view-transitionをはじめとする一連の擬似要素ツリー)が作成される
  7. レンダリングの一時停止が解除され、擬似要素がアニメーションしながら遷移する
  8. 遷移が完了したら、擬似要素は削除される

View Transitions API は画面遷移の前後で、状態のビジュアル的なスナップショットを生成します。上記の流れから分かるように、画面遷移のアニメーションを実現するのは遷移擬似要素と呼ばれる一連の擬似要素ツリーです。実際に Chrome DevTools で確認してみると、アニメーションの際に html要素 の直下に、次のような構造の擬似要素ツリーが生成されているのがわかります。

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ …other groups…

遷移擬似要素には以下のようなものがあります。

遷移擬似要素に対してスタイルを記述することで、アニメーションをカスタマイズすることが可能です。

アニメーションカスタマイズ例
@keyframes fade-in {
  from { opacity: 0; }
}
@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}
@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

名前付き画面遷移

CSS の view-transition-name プロパティはキャプチャする画面遷移の中で特定の要素にタグ付けを行い、指定した名前で要素を個別にトラッキングします。トラッキングされた要素は、ページ全体の遷移とは独立してアニメーション化されます。

デモコードにおいて view-transition-name は次のように指定していました。

CSS
*[data-view-transition-name="title"] {
  view-transition-name: title;
}
*[data-view-transition-name="thumbnail"] {
  view-transition-name: thumbnail;
}

view-transition-name を指定した場合、root 以外にも ::view-transition-group() が作成されます。本記事のデモでは titlethumbnail という名前付けを行なっていたので、次のようのな遷移擬似要素ツリーが生成されています。

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
├─ ::view-transition-group(title)
│  └─ ::view-transition-image-pair(title)
│     ├─ ::view-transition-old(title)
│     └─ ::view-transition-new(title)
└─ ::view-transition-group(thumbnail)
   └─ ::view-transition-image-pair(thumbnail)
      ├─ ::view-transition-old(thumbnail)
      └─ ::view-transition-new(thumbnail)

view-transition-name を指定した要素に対しても、アニメーションのカスタマイズは可能です。

アニメーションカスタマイズの例
*[data-view-transition-name="title"] {
  view-transition-name: title;
}

@keyframes fade-in {
  from { opacity: 0; }
}
@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}
@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(title) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
::view-transition-new(title) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

以上で View Transitions API のデモにおける実装を簡単に確認しました。

ここまでのデモでは、一つの HTML ファイルに二つの画面を記述して、擬似的にページ遷移の動作を模倣していました。次のステップでは、一つの HTML ファイルに一つの画面を記述するようにファイルを分割します。つまり、hidden 属性を使って画面をだし分けするのではなく、別々の HTML ファイルにコードを記述します。

そのためにまずは Navigation API について確認してみましょう。

Navigation API は、ブラウザナビゲーションのアクションの開始、インターセプト、および管理を提供する API です。また履歴エントリを調べることも可能です。これは History APIwindow.location の後継となります。

Navigation API の詳細な説明については Navigation API - Web APIs | MDN をご参照ください。

デモページ&コード

Navigation API のデモページ&コードには以下になります。

https://vta-and-na.yend.dev/navigation-api/
https://github.com/yend724/view-transitions-api-and-navigation-api-demo/tree/main/src/navigation-api

Navigation API のデモ画面のキャプチャ

Navigation APIのデモ画面

では、コードを確認していきましょう。ページ遷移の挙動をわかりやすくするため、今回は二つの書籍詳細画面を用意しました。

HTML
<!-- 書籍一覧画面 -->
<main class="main" data-transition-wrapper="book-index">
  <h1 class="page-title">書籍一覧</h1>
  <div class="container">
    <div class="book-index-list">
      <article class="book">
        <a class="book-link link" href="/navigation-api/detail/i-am-a-cat/">
          <img class="thumbnail" data-view-transition-name="thumbnail" src="..." />
          <h2 class="book-title" data-view-transition-name="title">吾輩は猫である</h2>
          <div class="book-author">夏目 漱石</div>
        </a>
      </article>
      <article class="book">
        <a class="book-link link" href="/navigation-api/detail/rashomon/">
          <img class="thumbnail" data-view-transition-name="thumbnail" src="..." />
          <h2 class="book-title" data-view-transition-name="title">羅生門</h2>
          <div class="book-author">芥川 竜之介</div>
        </a>
      </article>
    </div>
  </div>
</main>
HTML
<!-- 書籍詳細画面(羅生門の画面も同様のDOM構造)-->
<main class="main" data-transition-wrapper="book-detail">
  <h1 class="page-title">書籍詳細</h1>
  <div class="container">
    <article class="book">
      <h2 class="book-title" data-view-transition-name="title">吾輩は猫である</h2>
      <img class="thumbnail" data-view-transition-name="thumbnail" src="..." />
      <p class="book-content">吾輩わがはいは猫である。名前はまだ無い。...</p>
      <div class="book-author">夏目 漱石</div>
    </article>
    <a class="link back-link" href="/navigation-api/">一覧へ戻る</a>
  </div>
</main>

上記の書籍詳細画面は「吾輩は猫である」のページになりますが、「羅生門」のページも同様な DOM 構造となっています。

Navigation API を使用した JavaScript のコードは以下になります。

JavaScript
// HTML のパース
const parser = new DOMParser();
const parseHTML = html => {
  return parser.parseFromString(html, 'text/html');
};
// fetch でHTMLの文字列の取得
const getHTML = async url => {
  return fetch(url).then(res => res.text());
};
// 要素の入れ替え
const swap = (from, to) => {
  from.replaceWith(to);
};

// インターセプトするべきでないイベント
const shouldNotIntercept = navigationEvent => {
  // 参考: https://developer.chrome.com/docs/web-platform/navigation-api/#deciding-how-to-handle-a-navigation
  return (
    !navigationEvent.canIntercept ||
    navigationEvent.hashChange ||
    navigationEvent.downloadRequest ||
    navigationEvent.formData
  );
};

// Navigation API
navigation.addEventListener('navigate', e => {
  if (shouldNotIntercept(e)) return;

  const loadNextPage = async () => {
    const htmlString = await getHTML(e.destination.url);
    const parsedHTML = parseHTML(htmlString);
    const toHTML = parsedHTML.querySelector('*[data-transition-wrapper]');
    const fromHTML = document.querySelector('*[data-transition-wrapper]');
    swap(fromHTML, toHTML);
    document.title = parsedHTML.title;
  };

  // NavigateEvent のインターセプト
  // handler に処理内容を記述
  e.intercept({ handler: loadNextPage });
});
navigation.addEventListener('navigatesuccess', e => {
  console.log(e);
});
navigation.addEventListener('navigateerror', e => {
  console.error(e);
});

この中で、とりわけ重要になるのは次の箇所です。

JavaScript
// Navigation API
navigation.addEventListener('navigate', e => {
  if (shouldNotIntercept(e)) return;

  const loadNextPage = async () => {
    const htmlString = await getHTML(e.destination.url);
    const parsedHTML = parseHTML(htmlString);
    const toHTML = parsedHTML.querySelector('*[data-transition-wrapper]');
    const fromHTML = document.querySelector('*[data-transition-wrapper]');
    swap(fromHTML, toHTML);
    document.title = parsedHTML.title;
  };

  // NavigateEvent をインターセプト
  // handler に処理内容を記述
  e.intercept({ handler: loadNextPage });
});

Navigation API は window.navigation を介してアクセスすることができます。NavigateEventintercept() は文字通りナビゲーションをインターセプトするメソッドで、同一ドキュメントへのナビゲーションに変換します。

handler はナビゲーションの処理動作がどうあるべきかを定義するコールバック関数です。ここでは loadNextPage()を設定しています。

JavaScript
// Navigation API
// `window.navigation` を介してアクセス
navigation.addEventListener('navigate', e => {
  // ...略

  // NavigateEvent をインターセプト
  // handler にコールバック関数で、どう振舞うべきかの処理を渡す
  e.intercept({ handler: loadNextPage });
});

loadNextPage() 内では次のコンテンツを fetch() して、取得した HTML 文字列を DOMParser で DOM に変換しています。その後 swap() 関数で、現在のページの画面を更新しています。

JavaScript
const loadNextPage = async () => {
  // e.destination.url はナビゲーション先の URL
  const htmlString = await getHTML(e.destination.url);
  // DOMに変換
  const parsedHTML = parseHTML(htmlString);
  const toHTML = parsedHTML.querySelector('*[data-transition-wrapper]');
  const fromHTML = document.querySelector('*[data-transition-wrapper]');
  // HTMLの更新
  swap(fromHTML, toHTML);
  document.title = parsedHTML.title;
};

shouldNotIntercept()に関しての詳細は Chrome Developers による記事を参照してください。インターセプトすべきでないナビゲーションイベントについて確認できます。

JavaScript
const shouldNotIntercept = navigationEvent => {
  // 参考: https://developer.chrome.com/docs/web-platform/navigation-api/#deciding-how-to-handle-a-navigation
  return (
    !navigationEvent.canIntercept ||
    navigationEvent.hashChange ||
    navigationEvent.downloadRequest ||
    navigationEvent.formData
  );
};

実際にページの挙動を確認してみると(アニメーションを実装していないこともあり)一見通常の a要素 によるページ遷移と違いがないように思えるかもしれません Chrome DevTools の Network タブを確認してみると、ページ遷移するたびに非同期で次のコンテンツを fetch() していることがわかります。

従来の方法である History API で似たようなことをする場合、a要素 のクリックイベントをハンドリングしたり、history.pushState() で履歴の管理を行う必要があります。Navigation API の登場によりこれらの処理が、より簡易で柔軟に実現可能となりました。

View Transitions API と Navigation API を組み合わせる

それでは、View Transitions API と Navigation API を組み合わせてページ遷移アニメーションを実装してましょう。

デモページ&コード

View Transitions API と Navigation API のデモページ&コードは以下になります。

https://vta-and-na.yend.dev/async-transition-animation/
https://github.com/yend724/view-transitions-api-and-navigation-api-demo/tree/main/src/async-transition-animation

View Transitions API と Navigation API デモ画面のキャプチャ

View Transitions API と Navigation API デモ画面

View Transitions API と Navigation API を用いたページ遷移アニメーションの実装

このデモではこれまでに登場した View Transitions API と Navigation API を組み合わせることが主なので、これといって目新しい話はありません。とりわけコードに関しては、ほぼほぼ変更がないので変更がある箇所のみ抜粋して記載します。

書籍一覧画面
<!-- 書籍一覧画面 -->
<article class="book">
  <a class="book-link link" href="/async-transition-animation/detail/i-am-a-cat/"
  >
    <img
      class="thumbnail" data-view-transition-name="thumbnail" style="--view-transition-name: thumbnail-1" src="..."
    />
    <h2 class="book-title" data-view-transition-name="title" style="--view-transition-name: title-1"
    >吾輩は猫である</h2>
    <div class="book-author">夏目 漱石</div>
  </a>
</article>
<article class="book">
  <a class="book-link link" href="/async-transition-animation/detail/rashomon/">
    <img class="thumbnail" data-view-transition-name="thumbnail" style="--view-transition-name: thumbnail-2" src="..." />
    <h2 class="book-title" data-view-transition-name="title" style="--view-transition-name: title-2">羅生門</h2>
    <div class="book-author">芥川 竜之介</div>
  </a>
</article>
書籍詳細画面
<!-- 書籍詳細画面 -->
<article class="book">
  <h2 class="book-title" data-view-transition-name="title" style="--view-transition-name: title-1"
  >吾輩は猫である</h2>
  <img class="thumbnail" data-view-transition-name="thumbnail" style="--view-transition-name: thumbnail-1" src="..." />
  <p class="book-content"> 吾輩わがはいは猫である。名前はまだ無い。... </p>
  <div class="book-author">夏目 漱石</div>
</article>
CSS
/* View Transitions API */
*[data-view-transition-name] {
  view-transition-name: var(--view-transition-name);
  contain: paint;
}
/* アニメーションの時間調整 */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 1s;
}

view-transition-name については注意が必要です。view-transition-name の値は一つの画面につき、一意である必要があります。つまり一覧画面ではタイトルごと、およびサムネイルごとに view-transition-name を変更する必要があり、今回は CSS 変数(--view-transition-name)を使用することで、要素ごとに一意な名前をつけています。

JavaScript
// View Transitions API
const swap = (from, to) => {
  return document.startViewTransition(() => {
    from.replaceWith(to);
  }).updateCallbackDone;
};

JavaScript は、Navigation API のデモからほとんど変更がありませんが、swap() の中身を document.startViewTransition() 内で処理しています。

View Transitions API と Navigation API を利用したページ遷移アニメーションのデモに関する説明は以上です。実際にページ遷移の挙動を確認してみると、非同期でアニメーション付きの画面遷移が行われていることを確認できると思います。

Cross Document View Transitions

ここで、さらにもう一歩先に進んだ Cross Document View Transitions の話題についても触れてみようと思います。

デモページ&コード

Cross Document View Transitions のデモページ&コードは以下になります。

https://view-transitions-api-and-navigation-api-demo.pages.dev/cross-document-view-transitions/
https://github.com/yend724/view-transitions-api-and-navigation-api-demo/tree/main/src/cross-document-view-transitions

Cross Document View Transitions のデモ画面のキャプチャ

Cross Document View Transitionsのデモ画面

MPA でのページ遷移アニメーション

これまでに触れてきた View Transitions API は主に同一ドキュメント、SPA (Single-page application) 向けの内容でした。つまり MPA(Multi-Page Application)では使用できず、ページ間で画面遷移を行いたい場合は fetch()非同期的に次のページの内容を取得する必要がありました。

一方で View Transitions API の異なるページ(ドキュメント)間での対応も考えられているようで、現時点では以下のように metaタグ を宣言する必要があります。

<meta name="view-transition" content="same-origin" />

実際に、単純なアニメーション付の遷移をするだけであれば JavaScript の記述すら不要になります( fetch() も不要です)。

挙動を確認したい方は前述した通り chrome://flags/#view-transition-on-navigation を Enabled にして、デモページにアクセスしてみてください。このデモページでは fetch() の処理を行なっていないのにも関わらず、アニメーション付きのページ遷移が行われています。

https://github.com/yend724/view-transitions-api-and-navigation-api-demo/blob/main/src/cross-document-view-transitions/index.html#L7
https://github.com/yend724/view-transitions-api-and-navigation-api-demo/blob/main/src/cross-document-view-transitions/assets/js/main.js#L1-L2

これは実際に MPA の挙動で実現されており、Chrome DevTools の Network タブを確認しても、同期的にページ遷移していることが確認できると思います。

終わりに

この記事では、View Transitions API と Navigation API を用いたページ遷移アニメーションの実装方法を紹介しました。

最初に Cross Document View Transitions の内容を読んだときは、JavaScript の記述すら不要になるのかと衝撃でした。他のブラウザも考慮すると、広くサポートされるまでにはもう少し時間がかかるかもしれませんが、引き続きキャッチアップしていきたいと思います。

参考

https://drafts.csswg.org/css-view-transitions/
https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
https://ics.media/entry/230510/
https://zenn.dev/yhatt/articles/cfa6c78fabc8fa
https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api
https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
https://blog.jxck.io/entries/2022-04-22/navigation-api.html
https://developer.chrome.com/blog/spa-view-transitions-land/#transitions-across-documents

Discussion