こんにちは!令和トラベルのフロントエンドエンジニアの福田です。
異なるDOM状態間のアニメーション遷移を可能にするView Transitions APIを使えば、驚くほど簡単にシームレスなアニメーションを追加できます。それにより、視覚的な一貫性を保つことができ、異なるページやコンテンツ間でも文脈を失わずに操作を行えることで、自然に受け入れやすいUXを提供できます。
今回の記事では、そんなView Transitions APIの仕組みや導入手順を、実装を交えてざっくり解説します!
View Transitions API とは?
View Transitions APIは、異なるDOM要素間のアニメーションを簡単に実装するためのAPIです。このAPIを使用することで、ウェブページの遷移を滑らかにし、UXを向上させることができます。特に、ページ間のトランジションをアニメーション化することで、視覚的な連続性を保つことが可能です。
どんなことができるのか
従来、ページ遷移時にスムーズなアニメーションを実現するには、手動で複雑なアニメーションをコーディングする必要がありました。しかし、View Transitions APIを使用することで、次のようなことが簡単に実現可能になります。
2024年6月11日リリースのChrome126から、SPAだけでなく、MPAにも対応してるようです。
- 異なる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のデフォルトの動きを図解すると
実際に動かしてみる
では実際に ViewTransitions API を使用して、連続性のあるアニメーションを自社のブログに追加していきます。
具体的には、以下のようにブログ内の記事カードをクリックし、記事詳細に遷移する部分での挙動に連続性を与えていきます。
準備
↓こちらを参考に実装を進めます。
versionは以下です。
next v14.2.3 (App Router) react v18.3.1
まずは、View Transitions API はまだTypeScriptで型定義されていないため、独自で型を定義します。
interface ViewTransition { ready: Promise<void>; finished: Promise<void>; updateCallbackDone: Promise<void>; } interface Document { startViewTransition?: (cb: () => Promise<void> | void) => ViewTransition; }
次に、View Transitions API を適用するために必要な startViewTransition を遷移時に発火するためのカスタムフックを追加します。
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コンポーネントを実装していきます。
"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 を適用していきます。
早速、記事カードを変えてきます。
- 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> ); };
加えて、ヘッダー内のリンクも変更します。
- 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
を使用します。これを記事カードのサムネイルと記事詳細のメイン画像の両方につけることで、遷移時にリッチなアニメーションを実現できます。
View Transitions APIの擬似要素ツリーについて
View Transitions APIは、擬似要素ツリーを構築し、それぞれ以下のような特徴を持つ。
::view-transition
: 全ての要素の上に配置され、遷移の背景色を設定する場合などに適切::view-transition-group()
: view-transition-nameで設定された名前空間上のルート::view-transition-image-pair()
: 新旧ビュー(トランジション前とトランジション後)のコンテナー::view-transiton-old
: 古いビューのスクリーンショット::view-transiton-new
: 新しいビューのライブ表現デフォルトでは、以下のような構造になっています。
::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とは別にアニメーションを適用することができます。
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> ); };
/* 追加 */ .viewTransitionImage { view-transition-name: post-image; }
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> ); };
/* 追加 */ .mainImage { view-transition-name: post-image; }
viewTransitionImage
というクラス名に、view-transition-name
に post-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を目指していきましょう!
令和トラベルでは一緒に働く仲間を募集しています
令和トラベルでは、一緒に事業成長を牽引いただける仲間を絶賛募集中です!令和トラベルやNEWTに少しでも興味をお持ちいただけましたら、ご連絡お待ちしています。
フランクに話だけでも聞きたいという方は、カジュアル面談も実施できますので、お気軽にお声がけください。
『NEWT Tech Talk』のお知らせ
また、令和トラベルでは定期的に技術や組織に関する情報発信を開催しています。イベントの最新情報については、connpass にてメンバー登録して最新情報をお見逃しなく!
【9月開催】After iOSDC & DroidKaigi 2024 | Mobile LT Night
8/22~24に開催された「iOSDC Japan 2024」と9/11~13に開催された「DroidKaigi 2024」のアフターイベントとして、GO株式会社とnote株式会社と株式会社令和トラベルの3社合同で『Mobile LT Night』を9月30日に開催します。
当日は、各社よりiOS/Androidエンジニア1名ずつ、合計6名がLTに登壇します。今年の国内最大の2つのカンファレンスを振り返りつつ、モバイルアプリ開発に関する知見を共有しあえるコンテンツをご用意しています。オフライン・オンラインの同時開催なのでぜひご参加ください!
【10開催】『【特別編】急成長するNEWTの技術と戦略のすべてを語る 〜 次世代テクノロジーの未来 〜』
2024年9月にプレスリリースした資金調達を記念して、10月はNEWT Tech Talk特別バージョンで開催します!
代表取締役CEO篠塚がNEWT Tech Talkに初参戦🎊 2度目の資金調達を終え、旅行業界において令和トラベルが成し遂げようとしている変革やミッション、テクノロジーに投資していく背景や目指しているビジョンについてお話します。