View Transitions APIでアニメーション付きの画面遷移を行う

# View Transitions APIでアニメーション付きの画面遷移を行う

View Transitions APIを使用した画面遷移の実装の覚書です。

## View Transitions APIとは

View Transitions APIは、ページ間の移行やUI要素の状態変更をスムーズにアニメーション化するためのWeb APIです。
このAPIを使用することで、Webページの視覚的な変化をスムーズにできます。
シングルページアプリケーション（SPA）やマルチページアプリケーション（MPA）の両方で利用できます。

以下に、View Transitions APIの基本的な使用方法を説明します。

## 今回作るもの

以下のように、メイン画面とログイン画面をスムーズに遷移するようなシングルページアプリケーションを作成します。
![transition-demo2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406753/cc454f24-a4ac-c8ad-9af4-82f99a451fd2.gif)


JavaScriptフレームワークはReactを使用し、スタイリングはTailwind.cssを使用しています。
また、ルーティングにはReact Router V6を使用しています。

以下からは実装手順を記載します。

## 画面を作る

今回はサンプルのため、適当な画面を作成します。
React Routerでホーム画面とログイン画面で表示するコンポーネントが切り替わるようにします。

```React:App.js
import { FiHome, FiUser, FiArrowLeft } from "react-icons/fi";
import { Routes, Route, useNavigate } from "react-router-dom";

function App() {
  const navigate = useNavigate();

  return (
  <main className="select-none h-screen max-h-screen flex flex-col bg-gradient-to-b from-slate-50 to-slate-200">
    
    {/* ヘッダー */}
    <header className="p-4 w-full">
      <Routes>
        <Route path="/" element={
          <div className="flex items-center ml-0 mr-auto font-bold text-xl text-slate-950">
            <FiHome className="w-5 h-5 mr-4" />ホーム
          </div>
        }/>
        <Route path="/login" element={
          <div className="flex items-center ml-0 mr-auto font-bold text-xl text-slate-950">
            <FiArrowLeft onClick={() => { navigate(-1) }} className="w-5 h-5 mr-4" />ログイン
          </div>
        }/>
      </Routes>
    </header>

    {/* メインコンテンツ */}
    <section className="flex-grow overflow-y-auto">
      <Routes>
        <Route path="/" element={ <Home /> } />
        <Route path="/login" element={ <Login /> } />
      </Routes>
    </section>

    {/* フッター */}
    <footer className="bg-white flex">
      <Routes>
        <Route path="/" element={
          <div className="mx-auto my-2">
            <button onClick={() => { navigate('/login') }} className="flex items-center px-2.5 py-1 rounded-full text-blue-800 border border-blue-800 hover:text-white hover:bg-blue-800 transition-all">
              <FiUser className="mr-2"/>ログイン
            </button>
          </div> }/>
        <Route path="/login" element={ <></> }/>
      </Routes>
    </footer>
  </main>
  );
}

```

## View Transitions APIを呼び出す

通常の画面遷移が実装できたら、画面遷移を行う際に`document.startViewTransition`を呼び出すだけで、デフォルトのアニメーション（クロスフェード）を付けてくれます。
今回は、React Routerの`navigate`で画面遷移を行っていますので、トランジション付きの遷移をおこなうhookとしてラップします。

注意点として、View Transitions APIは新しいAPIのため、まだ対応していないブラウザもあります。
`startViewTransition`がUndefinedの場合、通常のトランジションなしの画面遷移を行うようにします。
また、Reactは画面を非同期に更新します。
`startViewTransition`に渡す画面更新処理は`flushSync`でラップして、同期的に更新したほうが良いかもしれません。

```React:navigationTransition.js
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { flushSync } from 'react-dom';

export const useNavigationTransition = () => {
  const navigate = useNavigate();

  const navigationTransition = useCallback(
    async (newRoute) => {
      if (!document.startViewTransition) {
        // Transition APIに非対応のブラウザの場合は通常の遷移
        return navigate(newRoute);
      }

      document.startViewTransition({
        update: () => {
          // 画面更新処理
          flushSync(() => {
            navigate(newRoute);
          });
        }
      });

      return;
    },
    [navigate]
  );

  return {
    navigationTransition
  };
};
```

```diff_jsx:App.jsx
 import { FiHome, FiUser, FiArrowLeft } from "react-icons/fi";
-import { Routes, Route, useNavigate } from "react-router-dom";
+import { Routes, Route } from "react-router-dom";
+import { useNavigationTransition } from "./navigationTransition";

 function App() {
-  const navigate = useNavigate();
+  const { navigationTransition } = useNavigationTransition();

   return (
   <main className="select-none h-screen max-h-screen flex flex-col bg-gradient-to-b from-slate-50 to-slate-200">
...
         <Route path="/login" element={
           <div className="flex items-center ml-0 mr-auto font-bold text-xl text-slate-950">
-            <FiArrowLeft onClick={() => { navigate(-1) }} className="w-5 h-5 mr-4" />ログイン
+            <FiArrowLeft onClick={() => { navigationTransition(-1) }} className="w-5 h-5 mr-4" />ログイン
           </div>
         }/>
       </Routes>
...
       <Routes>
         <Route path="/" element={
           <div className="mx-auto my-2">
-            <button onClick={() => { navigate('/login') }} className="flex items-center px-2.5 py-1 rounded-full text-blue-800 border border-blue-800 hover:text-white hover:bg-blue-800 transition-all">
+            <button onClick={() => { navigationTransition('/login') }} className="flex items-center px-2.5 py-1 rounded-full text-blue-800 border border-blue-800 hover:text-white hover:bg-blue-800 transition-all">               <FiUser className="mr-2"/>ログイン
             </button>
           </div> }/>

```

これだけで、以下のようなクロスフェードで画面が切り替わるアプリを実装できます。

![transition-crossfade-sm.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406753/ebae6285-31f5-39a3-3588-2ac147dbe4a4.gif)

## アニメーションをカスタマイズする

ここからは、スライドで画面が切り替わるようにアニメーションをカスタマイズします。
ブラウザは`startViewTransition`が呼び出されると、画面の更新を一時停止し、アンマウントされる要素とマウントされる要素のキャプチャをとります。
以下のような疑似要素ツリーが作成され、`::view-transition-old`と`::view-transition-new`に要素のキャプチャがそれぞれ割り当てられます。

```
::view-transition
└─ ::view-transition-group(root)
  └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)
```

なので、`::view-transition-old`と`::view-transition-new`にCSSでアニメーションを設定することで、アニメーションをカスタマイズできます。
また、`startViewTransition`に渡すオプションでアニメーションのタイプを自由に設定できます。
これによって、ページの進む、戻るなどのアクションに応じて個別にアニメーションを設定することができます。
今回はナビゲーションの際に文字列でパスを渡された場合は`forwards`、`-1`が渡された場合は`backwards`として、それぞれにアニメーションを設定します。

```diff_javascript:navigationTransition.js
document.startViewTransition({
        update: () => {
          // 画面更新処理
          flushSync(() => {
            navigate(newRoute);
          });
        },
+       types: [newRoute < 0 ? 'backwards' : 'forwards']
      });
```

```css:index.css

html:active-view-transition-type(forwards) {
  &::view-transition-old(root) {
    animation: 300ms ease-in both slide-to-left,
    100ms ease 200ms both fade-out;
  }
  &::view-transition-new(root) {
    animation: 300ms ease-in both slide-from-right,
    200ms ease 100ms both fade-in;
  }
}

html:active-view-transition-type(backwards) {
  &::view-transition-old(root) {
    animation: 300ms ease-in reverse both slide-from-right,
    100ms ease 200ms both fade-out;
  }
  &::view-transition-new(root) {
    animation: 300ms ease-in reverse both slide-to-left,
    200ms ease 100ms both fade-in;
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
@keyframes slide-from-right {
  from {
    transform: translateX(50%);
  }
}
@keyframes slide-to-left {
  to {
    transform: translateX(-50%);
  }
}
```

以上で以下のようにページの進む、戻るで画面全体がスライドするようになったかと思います。
![transition-slide-all-sm.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406753/ea41c362-2979-5712-b77f-96dc5347ab52.gif)

最後に、要素ごとに個別のアニメーションを設定します。
今回はヘッダーはアニメーションせず、メインコンテンツのみスライドするようにします。

CSSの`view-transition-name`で要素ごとに名前を自由に設定できます。
ヘッダーとメインコンテンツにTailwind.cssで以下のように名前を設定します。

```react:App.jsx
    ...
    
    {/* ヘッダー */}
    <header className="[view-transition-name:header] p-4 w-full">
      <Routes>
        <Route path="/" element={
          <div className="flex items-center ml-0 mr-auto font-bold text-xl text-slate-950">
            <FiHome className="w-5 h-5 mr-4" />ホーム
          </div>
        }/>
        
        ...
        
    {/* メインコンテンツ */}
    <section className="[view-transition-name:main-contents] flex-grow overflow-y-auto">
      <Routes>
        <Route path="/" element={ <Home /> } />
        <Route path="/login" element={ <Login /> } />
      </Routes>
    </section>
```

CSSを以下のように変更します。
注意点として、`startViewTransition`ではデフォルトでアニメーションが設定されています。
なので、アニメーションしたくない場合は、`animation: none`でアニメーションを切る必要があります。

```css
html:active-view-transition-type(forwards) {
  &::view-transition-old(main-contents) {
    animation: 300ms ease-in both slide-to-left,
    100ms ease 200ms both fade-out;
  }
  &::view-transition-new(main-contents) {
    animation: 300ms ease-in both slide-from-right,
    200ms ease 100ms both fade-in;
  }
}

html:active-view-transition-type(backwards) {
  &::view-transition-old(main-contents) {
    animation: 300ms ease-in reverse both slide-from-right,
    100ms ease 200ms both fade-out;
  }
  &::view-transition-new(main-contents) {
    animation: 300ms ease-in reverse both slide-to-left,
    200ms ease 100ms both fade-in;
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
@keyframes slide-from-right {
  from {
    transform: translateX(50%);
  }
}
@keyframes slide-to-left {
  to {
    transform: translateX(-50%);
  }
}

html:active-view-transition-type(forwards, backwards) {
  &::view-transition-old(header) {
    opacity: 1;
    animation: none;
  }
  &::view-transition-new(header) {
    opacity: 0;
    animation: none;
  }
}
```

以上で、最初の例のような画面遷移が実装できます。