🐣

簡単にシームレスな遷移を実装!View Transitions APIでページ遷移を進化させる

2024/09/04に公開

こんにちは!令和トラベルのフロントエンドエンジニアの福田です。

異なるDOM状態間のアニメーション遷移を可能にするView Transitions APIを使えば、驚くほど簡単にシームレスなアニメーションを追加できます。それにより、視覚的な一貫性を保つことができ、異なるページやコンテンツ間でも文脈を失わずに操作を行えることで、自然に受け入れやすいUXを提供できます。
今回の記事では、そんなView Transitions APIの仕組みや導入手順を、実装を交えてざっくり解説します!

View Transitions API とは?

View Transitions APIは、異なるDOM要素間のアニメーションを簡単に実装するためのAPIです。このAPIを使用することで、ウェブページの遷移を滑らかにし、UXを向上させることができます。特に、ページ間のトランジションをアニメーション化することで、視覚的な連続性を保つことが可能です。

https://developer.mozilla.org/ja/docs/Web/API/View_Transitions_API

どんなことができるのか

従来、ページ遷移時にスムーズなアニメーションを実現するには、手動で複雑なアニメーションをコーディングする必要がありました。しかし、View Transitions APIを使用することで、次のようなことが簡単に実現可能になります。

  • 異なるDOM要素間のアニメーション
    View Transitions APIの主な特徴の一つは、異なるDOM要素間でのアニメーションを簡単に実現できることです。これにより、ユーザーが操作する際に、コンテンツの変更がスムーズで視覚的に自然に感じられるようになります。

  • ページ遷移のアニメーション
    View Transitions APIを使用すると、ページ遷移時にもアニメーションを簡単に適用することができます。

  • CSSによるアニメーションのカスタマイズ
    View Transitions APIでは、デフォルトのアニメーション効果(クロスフェードなど)をCSSでカスタマイズできます。これにより、デザインやブランドガイドラインに合わせたアニメーションを作成することが可能です。

どのように動いているのか

View Transitions APIは、ブラウザのネイティブ機能を利用して、DOMの状態が変化する前後でスナップショットをキャプチャし、それらの間を滑らかに遷移させることでアニメーションを実現します。

このAPIは、ブラウザによって提供される最適化されたアニメーションエンジンを使用するため、パフォーマンスの面でも優れています。さらに、JavaScriptとCSSを組み合わせることで、カスタマイズ可能な遷移効果を簡単に実装できるのも特徴です。

  • 基本的な動作: document.startViewTransition()
    View Transitions APIの基本的な動作は、document.startViewTransition()という関数を介して行われます。この関数は、DOMの更新が行われる前に呼び出され、その後のDOMの変化に基づいてトランジションを実行します。

    • DOMのスナップショット: startViewTransition()関数を呼び出すと、ブラウザはまず現在の表示状態のスナップショットをキャプチャします。その後、指定されたコールバック関数が実行され、DOMが更新されます。
    • アニメーションの生成: DOMが更新された後、ブラウザは新しい表示状態のスナップショットをキャプチャします。この2つのスナップショットの間でアニメーションを生成し、ユーザーに対してスムーズな遷移を提供します。

    例: document.startViewTransition(() => { /* DOMの更新処理 */ });
    この例では、startViewTransitionが呼ばれた時点で現在のDOMの状態が保存され、コールバック内でDOMの更新が行われます。その後、新しいDOM状態に基づいてトランジションアニメーションが実行されます。

  • トランジションの実行プロセス
    View Transitions APIが実行される際のプロセスは、以下のように段階的に進行します。

    • トリガー: ユーザーの操作やJavaScriptコードの実行によって、DOM更新の必要が生じます。この時点で、startViewTransition()が呼び出されます。
    • スナップショットの取得: APIは、現在のDOM状態をスナップショットとして記録します。これにより、後で比較するための基準が確立されます。
    • DOMの更新: コールバック関数内でDOMの変更が行われます。これには、要素の追加、削除、属性の変更、テキストの変更などが含まれます。
    • 新しいスナップショットの取得: DOMの更新が完了したら、ブラウザは新しい状態をスナップショットとしてキャプチャします。
    • トランジションの実行: 2つのスナップショットの違いを元に、ブラウザは適切なアニメーションを生成します。要素の位置、サイズ、色、透明度などの変化がアニメーション化され、ユーザーに滑らかな変化として表示されます。

👇 View Transitions APIのデフォルトの動きを図解すると
View Transitions API図解

実際に動かしてみる

では実際に ViewTransitions API を使用して、連続性のあるアニメーションを自社のブログに追加していきます。

具体的には、以下のようにブログ内の記事カードをクリックし、記事詳細に遷移する部分での挙動に連続性を与えていきます。

準備

↓こちらを参考に実装を進めます。
https://github.com/vercel/next.js/discussions/46300

versionは以下です。

next v14.2.3 (App Router)
react v18.3.1

まずは、View Transitions API はまだTypeScriptで型定義されていないため、独自で型を定義します。

global.d.ts
interface ViewTransition {
  ready: Promise<void>;
  finished: Promise<void>;
  updateCallbackDone: Promise<void>;
}

interface Document {
    startViewTransition?: (cb: () => Promise<void> | void) => ViewTransition;
}

次に、View Transitions API を適用するために必要な startViewTransition を遷移時に発火するためのカスタムフックを追加します。

useViewTransitionRouter.ts
import { useLayoutEffect, useRef } from "react";
import { useRouter as useNextRouter, usePathname } from "next/navigation";

export const useViewTransitionRouter = (): ReturnType<typeof useNextRouter> => {
  const router = useNextRouter();
  const pathname = usePathname();

  const promiseCallbacks = useRef<Record<
    "resolve" | "reject",
    (value: unknown) => void
  > | null>(null);

  const transitionHelper = (updateDOM: () => Promise<void> | void) => {
    if (!document.startViewTransition) {
      return updateDOM();
    }

    document.startViewTransition(updateDOM);
  };

  useLayoutEffect(() => {
    return () => {
      if (promiseCallbacks.current) {
        promiseCallbacks.current.resolve(undefined);
        promiseCallbacks.current = null;
      }
    };
  }, [pathname]);

  return {
    ...router,
    push: (...args: Parameters<typeof router.push>) => {
      transitionHelper(() => {
        const url = args[0] as string;
        if (url === pathname) {
          router.push(...args);
        } else {
          return new Promise((resolve, reject) => {
            // @ts-ignore
            promiseCallbacks.current = { resolve, reject };
            router.push(...args);
          });
        }
      });
    },
  };
};

ポイントは以下の2点です。

  • カスタムフックの push メソッドが、transitionHelper によりラップされ、遷移時に startViewTransition が実行されるようにしている点
  • ページ遷移の処理後に、アニメーション動作を実行するため、router.push をPromiseでラップしている点

これにより、
router.push の呼び出し時に、promiseCallbacks にresolve・rejectが保持されます。
そして、ページ遷移完了後に useLayoutEffect が実行され、その後に document.startViewTransition が実行されるようになります。

では、最後に next/link をラップして、View Transitions API 用のLinkコンポーネントを実装していきます。

ViewTransitionsLink.ts
"use client";

import Link from "next/link";
import { ComponentProps, FC } from "react";
import { useViewTransitionRouter } from "./useViewTransitionRouter";

type Props = ComponentProps<typeof Link>;

export const ViewTransitionsLink: FC<Props> = ({ ...nextLinkProps }) => {
  const router = useViewTransitionRouter();

  const handleLinkClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();

    router.push(e.currentTarget.href.toString());
  };

  return <Link {...nextLinkProps} onClick={handleLinkClick} />;
};

こちらは、onClickで先ほどのカスタムフックの router.push を実行するようにしています。

ブログ内の遷移のLinkを差し替えてみる

では、ブログ内の遷移に View Tarnsitions API を適用していきます。

早速、記事カードを変えてきます。

PostCard.
- import Link from "next/link";
+ import { ViewTransitionsLink } from "../ViewTransitionsLink";

export const PostCard: FC<{ post: NotionPost }> = ({ post }) => {
  return (
-    <Link
+    <ViewTransitionsLink
      href={ROUTES.blog.view(post.slug)}
    >
      <article className={styles.root}>
        {/** MAIN IMAGE */}
        {post?.mainImage?.[0].url && (
          <Image
            className={styles.image}
            src={post.mainImage[0].url || ""}
            alt={post.title}
            width={1280}
            height={670}
            style={{
              width: "100%",
              height: "auto",
            }}
          />
        )}
        {/** HEADER */}
        {/** TITLE */}
        {/** TAGS */}
      </article>
-   </Link>
+   </ViewTransitionsLink>
  );
};

加えて、ヘッダー内のリンクも変更します。

Header.tsx
- import Link from "next/link";
+ import { ViewTransitionsLink } from "../ViewTransitionsLink";

export const Header = () => {
  return (
    <header className={styles.root}>
      // ・・・・・
-       <Link href={ROUTES.home()}>
+       <ViewTransitionsLink href={ROUTES.home()}>
          <Logo />
-       </Link>
+       </ViewTransitionsLink>
      // ・・・・・
    </header>
  );
};

こちらは、単に next/link を 先ほど自作した ViewTransitionsLink で書き換えただけです。

すると、ページ遷移のアニメーションは以下のようになります。

これでデフォルトのクロスフェードは実装できました。

少しアニメーションを追加してみる

もう少しアニメーションを工夫してみます。

記事カードのサムネイルと記事詳細のメイン画像を、遷移時に紐付いているようなアニメーションを実装していきます。
そのためには view-transition-name を使用します。
https://developer.mozilla.org/ja/docs/Web/CSS/view-transition-name

これを記事カードのサムネイルと記事詳細のメイン画像の両方につけることで、遷移時にリッチなアニメーションを実現できます。

View Transitions APIの擬似要素ツリーについて

View Transitions APIは、擬似要素ツリーを構築し、それぞれ以下のような特徴を持つ。
::view-transition: 全ての要素の上に配置され、遷移の背景色を設定する場合などに適切
::view-transition-group(): view-transition-nameで設定された名前空間上のルート
::view-transition-image-pair(): 新旧ビュー(トランジション前とトランジション後)のコンテナー
::view-transiton-old: 古いビューのスクリーンショット
::view-transiton-new: 新しいビューのライブ表現

https://developer.mozilla.org/ja/docs/Web/API/View_Transitions_API#css_の追加

デフォルトでは、以下のような構造になっています。

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

これに、

figcaption {
  view-transition-name: figure-caption;
}

のようにすると、

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

のようになり、rootとは別にアニメーションを適用することができます。

PostCard.tsx
export const PostCard: FC<{ post: NotionPost }> = ({ post }) => {
+ const [isTargetCard, setIsTargetCard] = useState(false);

  return (
    <ViewTransitionsLink
      href={ROUTES.blog.view(post.slug)}
      // クラスをつけるため要素ホバー時にstateを更新
+     onMouseOver={() => setIsTargetCard(true)}
+     onMouseLeave={() => setIsTargetCard(false)}
    >
      <article className={styles.root}>
        {/** MAIN IMAGE */}
        {post?.mainImage?.[0].url && (
          <Image
-           className={styles.image}
            // アニメーションをつけたい画像にだけクラスをつける
+           className={`${styles.image} ${
+             isTargetCard ? styles.viewTransitionImage : ""
+           }`}
            src={post.mainImage[0].url || ""}
            alt={post.title}
            width={1280}
            height={670}
            style={{
              width: "100%",
              height: "auto",
            }}
          />
        )}
        {/** HEADER */}
        {/** TITLE */}
        {/** TAGS */}
      </article>
    </ViewTransitionsLink>
  );
};
PostCard.module.css
/* 追加 */
.viewTransitionImage {
  view-transition-name: post-image;
}
PostDetail.ts
export const PostDetailPage: FC<PostDetailPageProps> = ({
  post,
  recordMap,
}) => {
  return (
    <div className={styles.root}>
      // ・・・・
        <Image
          // アニメーション用のクラスをつける
+         className={styles.mainImage}
          src={post.mainImage.url || ""}
          alt={post.title}
          width={1280}
          height={670}
          style={{
            width: "100%",
            height: "auto",
          }}
        />
      // ・・・・
    </div>
  );
};
PostDetail.module.css
/* 追加 */
.mainImage {
  view-transition-name: post-image;
}

viewTransitionImage というクラス名に、view-transition-namepost-image という名前をつけ、アニメーションを追加したい要素にクラス名を付与します。
そして、遷移後に紐づけたい要素にも同じ view-transition-name をつけます。
この view-transition-name が同一のものが複数存在してしまうとうまくアニメーションしてくれません。

なので、PostCard.tsxを以下のように実装を加えています。

  • useStateを isTargetCard で、選択される記事カードのみに viewTransitionImage をつける
  • onMouseOver、onMouseLeaveを使用して、クラスをつけるため要素ホバー時にstateを更新する

すると、以下のような紐づけられた画像同士のアニメーションが追加されます。

補足

next/navigation をラップすれば、App Routerでも問題なく動作しますが、App Routerではpopstateイベント時に処理を挟むということができないため、ブラウザバックなどでの動作はうまくいかないみたい?です。

まとめ

View Transitions APIを使うことで、ページ遷移のような一見難しそうなアニメーションの実装も簡単に実装できるようになります。
ブラウザバックのようなものを除けば、App Routerでも問題なく動作する点もとてもいいと思いました。

今回の画像のアニメーション以外にも、どう活用するか次第でいくらでももっとリッチなアニメーションが実現できるので、ぜひView Transitions APIを使って、より良いUXを目指していきましょう!

令和トラベル Tech Blog

Discussion