🌟

View Transitions APIによるスムーズなページ遷移をNext.jsで簡単に試す

2023/04/13に公開
2

はじめに

こんにちは、ziと申します。
今回は、発表からしばらく経ってしまいましたが、View Transitions APIを使ったNext.jsでのスムーズなページ・UI遷移を目指して、実装してみます。
https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API

※もし間違いやより良い実装方法など見つけましたら、ご指摘ください! 🙇‍♂️

今回実装した最終成果はこちらです。
https://nextjs-view-transitions-api-dusky.vercel.app/
また、コードはこちらにあります。
https://github.com/RyojiK74/nextjs-view-transitions-api

View Transitions APIとは?

View Transitions APIとは、2023年4月11日現在Chrome 111とOpera 97(pre-release)以降で実装されている遷移のアニメーションを行うブラウザーAPIです。下記は、MDNからの引用です。

View transitions are a popular design choice for reducing users' cognitive load, helping them stay in context, and reducing perceived loading latency as they move between states or views of an application. 
View Transitions API:  https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API#concepts_and_usage

View Transitionsはアプリケーションのビューや状態間で遷移することで、ユーザーの認知負荷を減らし、UI間の文脈をつなげ、体感ロード遅延を減少させます。(筆者意訳)

スムーズな遷移はユーザー体験を向上できますが、従来のSPAでもページ遷移などのアニメーションをする際、DOMの追加、削除、遷移中のキャンセル対応などが煩雑になりがちでした。ですが、このAPIがその辺りをカバーしてくれることで、開発者はDOM更新が必要な箇所に集中することができます。

ここ自分は勘違いしていたのですが、現状はSPAでの利用が主です。ただし、metaタグを利用することでMPAでも一部利用ができるようになるのと、将来的にはMPAでも利用できるようにしていくようです。
https://github.com/WICG/view-transitions/blob/main/explainer.md#cross-document-same-origin-transitions

The current spec and experimental implementation in Chrome (behind the chrome://flags/#document-transition flag) focuses on SPA transitions. However, the model has also been designed to work with cross-document navigations. The specifics for cross-document navigations are covered later in this document.
view-transitions/explainer.md: https://github.com/WICG/view-transitions/blob/main/explainer.md#mpa-vs-spa-solutions

現行のChromeでの仕様と実装はSPAの遷移に重きを置いています。ただし、このモデルはクロスドキュメントでのナビゲーションにも使えるようにデザインされており、クロスドキュメントナビゲーションに関しては後述しています。(筆者意訳)

もう一点、これまた勘違いしていたポイントなんですが、View Transitions APIはページ遷移に限った話ではなく、2要素間のトランジションであれば利用できます。 スライドショーなどがパッと思いつく例ですね。

View Transitions APIの基本

遷移アニメーションのざっくりとした仕組み

今回は実装面を主にするため、詳しい仕組みには触れませんが、ページ遷移時の流れは以下のようになっているようです。

  1. UI遷移時にView Transitions APIの呼び出し
  2. View Transitions APIに渡していたDOM更新をするコールバックの呼び出し
  3. View Transitions APIが新しいUIの状態(コールバック内部の状態変化)を捕捉
  4. ページの前後を遷移させるための擬似クラスの生成
  5. 古いページの表示がopacity: 0;へとアニメーションし、新しいページがopacity: 1;へとアニメーションする(デフォルトではクロスフェードでアニメーションする)
  6. 完了

APIを利用する側としては、View Transitions APIに渡すDOM更新をするコールバックとanimation用のCSSを書くだけになります。

今回主に使うAPIやプロパティ

今回主に利用するAPIやプロパティは以下のものになります。

  1. document.startViewTransition
    Transitionを発生させるためのブラウザーAPIです。引数にDOM更新をするコールバックを渡します。主に、リンクのオンクリックで発火させます。
  2. view-transition-name
    Transitionの前後で同一のエレメントを識別するために使う、CSSプロパティです。noneという値は予約されているので使えない点とページ内でユニークである必要がある点は注意です。
  3. ::view-transition-old, ::view-transition-new
    Transitionの前後で付与される擬似クラスです。クロスフェード以外のアニメーションを利用する場合はここに記述します。また、引数に上記で設定したview-transition-nameを入れることで対応した要素に対するアニメーションを記述できます。

これ以外にも、いろんなAPIがあるようです。
https://developer.chrome.com/docs/web-platform/view-transitions/#api-reference

Next.jsでView Transitions APIを使う方法

ここからが本題です。
今回は、Next.jsを使ってView Transitions APIによるスムーズなページ遷移・UI遷移を実装します。

まとめにデプロイしたものとコードのリンクを置いておきましたので、気になる方はぜひそちらもご覧ください。

Next.jsとView Transitions APIの相性

Next.jsのページ遷移はnext/routerを利用した場合、ブラウザ側でレンダリングと遷移が実行されます。
つまり、ページ遷移の観点ではView Transitions APIを呼び出した時に、next/router側で遷移すれば、スムーズなページ遷移が実装できます。

具体的には、ページ遷移をスムーズにしたい箇所で、

  1. リンクのクリックに対して、startViewTransitionを実行する。
  2. コールバックでnext/routerを使ったrouter.pushをする。

という簡単な2ステップでスムーズなページ遷移が実現できます。
https://nextjs.org/docs/api-reference/next/router

Next.jsにはページ遷移の方法としては、next/router, next/link, 通常通りのaタグと種類がありますが、今回はView Transitions APIの利用のためにnext/routerを利用した形にします。
(aタグのonClickにView Transitions APIを発火させても、同じように動きますが、今回はnext/linkのprefetch機能も使いたかったのでnext/linkにしています。(prefetchが効いているのかは未検証))

また、UIの遷移に関しても宣言的UIで更新もstartViewTransitionで捕捉されるため問題なく使えます。(ただし若干のハマりポイントがあったので後述します。)

環境のセットアップ

今回は、簡単にトップページにアイテムのリスト、そのリストをクリックした時に、スムーズに詳細ページに遷移するページを実装します。

今回は自分の好きなポケモンをリストして、クリックしたら詳細が出るようにします。ポケモンの情報などはpokeAPI経由で取得します。
https://pokeapi.co/

今回は、Next.js 13.3.0を利用します。
追記:将来的にこの辺のサポートが増えていく可能性があるので、ぜひ最新情報を見てみてください。
既にakfm.satoさんがdiscussionを上げてくださっていて、View Transitions APIが広く実装されればNext.js公式のサポートがつく可能性はありそうです。
https://github.com/vercel/next.js/discussions/46300

とりあえず、ざっと作ったページのデザインはこんな感じです。

リストページ

詳細ページ

View Transitions APIを使う

View Transitions APIはページ遷移だけでなく、UIの変更にも使えますが、今回は主にページ遷移に重きを置いて使っていきます。

View Transitions APIのカスタムフックの作成

まずは、View Transitions APIの大元のメソッド周りを固めます。
とりあえず、共通化できるロジックが出たらカスタムフックに押し込めるのが、サガですね。

useViewTransition/index.ts
export const useViewTransition = <T extends (...args: any[]) => void>(
  callback: T
) => {
  const startViewTransition = (...args: Parameters<T>) => {
    if (!(document as any).startViewTransition) {
      callback(...args);
      return;
    }

    (document as any).startViewTransition(async () => {
      await Promise.resolve(callback(...args));
    });
  };
  return { startViewTransition };
};

今回は、startViewTransitionが使えないブラウザの場合はそのままDOM更新を行うコールバックを実行するだけのカスタムフックにしました。また、router.pushのPromiseまでtransitionを待って欲しかったのでstartViewTransitionはPromiseでcallbackをラップしてawaitしています。
また、前述の通りnext/routerとの組み合わせもあるので、上記カスタムフックと組み合わせてrouter.push用のカスタムフックも作りました。

useTransitionRouterPush/index.ts
import { useRouter } from "next/router";
import { useCallback } from "react";
import { useViewTransition } from "../useViewTransition";

export const useTransitionRouterPush = () => {
  const router = useRouter();
  const routerPush = useCallback(
    async (to: string) => {
      await router.push(to);
    },
    [router]
  );
  const { startViewTransition: routerPushWithTransition } =
    useViewTransition(routerPush);

  return { routerPushWithTransition };
};

といっても、内容的には先ほどのuseViewTransitionの引数のコールバック関数をあらかじめ用意しているだけです。

次に、next/routerを利用したstartViewTransitionによってページ遷移をさせるリンクコンポーネントを作成します。

TransitionLink/index.tsx
import { useTransitionRouterPush } from "@/hooks/useTransitionRouterPush";
import Link from "next/link";
import { FC, useCallback } from "react";

type Props = {
  href?: string;
  children: React.ReactNode;
  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};

export const TransitionLink: FC<Props> = ({ href, children, onClick }) => {
  const { routerPushWithTransition } = useTransitionRouterPush();

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLAnchorElement>) => {
      e.preventDefault();
      if (onClick) {
        onClick(e);
      }

      const to = e.currentTarget.href;
      routerPushWithTransition(to);
    },
    [routerPushWithTransition, onClick]
  );

  return (
    <Link href={href} onClick={handleClick}>
      {children}
    </Link>
  );
};

ここでは、LinkのonClickにハンドラをつけて、クリックした要素のhrefに遷移させるように先ほど作ったuseTransitionRouterPushフックからできる関数に渡しています。
ここで、なぜこの関数内でuseTransitionRouterPushの内部を直接実行しないで切り分けたのか疑問に思うかもしれませんが、そこは後の「ブラウザバック時にtransitionをつける」で後述します。

ここまでの簡単な実装のみでも、デフォルトのクロスフェードがかかりいい感じになります。

ポケモンの画像遷移をスムーズにする

次に、ポケモンの画像をページ間でスムーズに繋げます。
ここでは、前述のview-transition-nameを使って、リストページのポケモンの画像と詳細ページのポケモンの画像が同じものであることを示します。

.pokemonImage {
  view-transition-name: pokemon-image;
}

上記のCSSをリストのポケモンの画像と詳細の方それぞれにつければ良いのですが、注意点としては、view-transition-nameの値のものは各ページでユニークでなければいけません。じゃないと、ページ遷移のタイミングでどの要素とどの要素を同一のものとしてみれば良いのかわからなくなるからです。

おそらく、やり方としては

  1. ポケモンの数だけview-transition-nameを発行して、ページ内で表示するポケモンに応じて割り振る
  2. クリックされたポケモンの画像にだけ、上記のCSSをつける

今回は、2で行いました。

PokemonTile/index.tsx
export const PokemonTile: FC<Props> = ({ no, name, image, types }) => {
  const [imageClassName, setImageClassName] = useState(styles.pokemonImage);

  return (
    <TransitionLink
      href={`/detail/${no}`}
      onClick={() => {
        setImageClassName(
          clsx(styles.pokemonImage, viewTransitionName.pokemonImage)
        );
      }}
    >
      <div className={styles.pokemonImageOuter}>
        <Image
          src={image}
          alt={name}
          className={imageClassName}
          width="160"
          height="160"
        />
      </div>

具体的な実装としては、前述のTransitionLinkonClickで遷移前にclassNameを貼り付けています。以下を参考にしました。
https://developer.chrome.com/docs/web-platform/view-transitions/#transitioning-elements-dont-need-to-be-the-same-dom-element

これだけでこのようになります。

可愛いですね。

かなり気持ち良い動き方になりました。

スライドインにしてみる

最後に、全体的にデフォルトのクロスフェードになっているものをスタイリングして下からスッと入ってくるスライドインにしてみます。

::view-transition-old, ::view-transition-newのスタイルをglobalで編集します。

global.css
@keyframes slide-in {
  from {
    transform: translateY(50px);
  }
}

@keyframes slide-out {
  to {
    transform: translateY(-50px);
  }
}

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

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

::view-transition-old(root) {
  animation: 100ms linear both fade-out, 300ms linear both slide-out;
}

::view-transition-new(root) {
  animation: 100ms linear both fade-in, 300ms linear both slide-in;
}

::view-transition-old, ::view-transition-newのanimationを上書きするので、元々かかっているクロスフェードも別途定義して適用します。

完成系がこちら。

ちょっとローカルが重いので、クリックからラグがありますが、いい感じに下からニュッとスライドインするページ遷移を実現できました。

ハマったところ

ブラウザバック時にtransitionをつける

現状、カスタムで作ったコンポーネント経由では、スムーズな遷移ができていますが、ブラウザの戻るボタンの場合、startViewTransitionが発火しないためスムーズに切り替わりません。

これに対応するためにnext/routerbeforePopStateを利用します。
https://nextjs.org/docs/api-reference/next/router#routerbeforepopstate

_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  const { routerPushWithTransition } = useTransitionRouterPush();
  const router = useRouter();

  // ここでブラウザバックに対応させる
  useEffect(() => {
    router.beforePopState(({ as }) => {
      routerPushWithTransition(as);
      return false;
    });
  }, [router, routerPushWithTransition]);

  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

前述したrouter.pushだけ切り分けていた理由はここで再利用するためでした。
router.beforePopStateはHistory APIのstateを戻すpopstateイベントを監視するための関数で、こちらにstartViewTransitionを発火させる関数を挟んで遷移させることでブラウザバック時にも、スムーズなページ遷移ができます。

このあたりの実装は、この方の実装を参考にしました。
https://github.com/amotarao/view-transitions-api/tree/main/packages/app

onMouseEnter/onMouseLeave両方につけると切り替えがうまくいかない

こちらはハマったというかできていないところでもあるのですが、onMouseEnteronMouseLeavestartViewTransitionをつけて、hoverする時だけ何かトランジション込みで要素を変化させようとするとうまくいきませんでした。(今回はポケモンにホバーした時のみポケモンのアニメーションを起動させて、ポケモンのタイプの表示をしようとしましたが、ダメでした。)

詳細にまだ見きれていませんが、おそらくホバーしたタイミングでトランジションの最中に新しい状態のビューが古い状態のビューに重なるために、

  1. ホバー
  2. onMouseEnterが発火
  3. 新しい状態のビューが重なる
  4. 古いビューに対してonMouseLeaveが発火
  5. ホバー前に戻る

を繰り返すような挙動になっていると思われます。

今回できていないところ

複数のview-transition-nameの付け替え

完成系としてあげているものをみていただければわかると思いますが、「次へ」や「前へ」に行く際にポケモンの画像だけスライドしていません。

これは、リストから詳細に遷移する時に指定したpokemon-imageというview-transition-nameがずっとついているため、スライドの対象外になってしまっています。一つの要素に対して、複数の動かし方をしたい場合は、(おそらく)view-transition-nameを付け替える必要がありますが、今回はそこまで手が回りませんでした。
また、ページ遷移で付け替える場合は次のページがどのページなのか、前のページがなんなのかを把握しないといけないため、割と実装が面倒&煩雑になりそうです。

「戻る」の時のview-transition-nameの付け替え

上記の点に似ていますが、詳細ページ遷移時にはできているポケモンの画像のスムーズな遷移がブラウザバック時にはできていません。こちらも、時間の問題でできなかったポイントです。

また、こちらに関しても全体ページに戻る時に、どのページから戻ってきているのかがわからないと、詳細ページで表示している画像とリストページのどの画像にview-transition-nameを同一に貼り付ければいいかわからないので、若干面倒&煩雑ポイントです。
今回は、表示するポケモンの数が限られているのでもしかすると全てにユニークなview-transition-nameを割り振ることで、この辺りが単純になりますが、リスト内の要素が多い場合は工夫が入りそうです。(CSS-in-JSなどなら値も動的にできるため楽かもと今思いました。)

追記:
ポケモンの画像それぞれに個別のviewTransitionNameをつけたら、問題なく動きました。

export const DetailDescription: FC<Props> = ({
  no,
  name,
  sprite,
  types,
  flavorText,
}) => {
  return (
    <section className={styles.section}>
      <div className={styles.pokemonImageOuter}>
        <Image
          src={sprite}
          width="160"
          height="160"
          alt=""
          className={styles.pokemonImage}
          style={{
            viewTransitionName: `pokemon-image-${no}`,
          }}
        />
      </div>

注意点

対応ブラウザの問題

View Transitions APIは現状Chrome 111/ Edge 111/ Opera 97(pre-release)のみで実装されている機能です。FirefoxやSafariではまだ実装されていないので、startViewTransitionのみを使って実装をするとブラウザ間でユーザー体験が変わってしまいます。

実装の問題

今回の例のように、元々スムーズな遷移がついていない箇所にView Transtions APIを使って対応ブラウザのみ実装する場合は問題ないですが、既にスムーズなUI遷移がついている箇所にこの実装をするのは微妙です。
そのようにした場合、元々の実装とView Transitions APIの両方の実装が必要になってしまうので、View Transitions APIの旨味である「簡単に2要素間のトランジションを書ける」という強みが消えてしまうからです。
ですので、現状の使い道としては、使うブラウザ間で違いが出てしまうのを許容した上で、元々スムーズなページ遷移ができていないところに使うのが主になるかと思いました。

まとめ

今回は、Next.jsでView Transitions APIを使って簡単にページ遷移をスムーズにする実装を行いました。
難しいDOM操作が不要な点はとても良く、こんな簡単にスムーズなページ遷移が実装できるんだと思う反面、新しいAPIへの慣れや今後デザイナーの方と相談をする時に考えることが増えそうだなという感想も持ちました。
また、WebアプリやPWAがよりNativeアプリのような体験に近づくと思うので、その辺りはかなりワクワクします。

今回実装した最終成果はこちらです。
https://nextjs-view-transitions-api-dusky.vercel.app/

また、コードはこちらにあります。
https://github.com/RyojiK74/nextjs-view-transitions-api

参考資料

https://www.w3.org/TR/css-view-transitions-1/
https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
https://zenn.dev/yhatt/articles/cfa6c78fabc8fa#おわりに
https://github.com/vercel/next.js/issues/46244
https://github.com/amotarao/view-transitions-api/tree/main/packages/app

Discussion

Knob/のまど先生Knob/のまど先生

とても素敵な記事をありがとうございます!
コメント失礼いたします.

useTransitionRouterPushのソースが実際のコードと異なりハマったのでコメントさせていただきます🙇‍♂️

export const useTransitionRouterPush = () => {
  const router = useRouter();
  const routerPush = useCallback(
    async (to: string) => {
      await router.push(to);
    },
    [router]
  );
  const { startViewTransition: routerPushWithTransition } =
    useViewTransition(routerPush);

  return { routerPushWithTransition };
};

async await が抜けており,これだとポケモンの画像遷移をスムーズにするのところから動作しないようでした!

zizi

おお、本当ですね。失礼いたしました 🙇
先ほど修正いたしました!ご指摘ありがとうございました!