<ViewTransition> で要素を囲むと、その要素の出入り・移動・リサイズがアニメーション化されます。
import { ViewTransition, useState, startTransition } from "react";export default function Component() { const [showItem, setShowItem] = useState(false); return ( <> <button className="rounded-sm border px-3 py-1.5" onClick={() => { startTransition(() => { // [!code highlight] setShowItem((prev) => !prev); }); }} > {showItem ? "➖ 非表示にする" : "➕ 表示する"} </button> {showItem && ( <ViewTransition> <div className="mt-4 w-60 rounded-lg bg-blue-500 p-6 text-white"> ViewTransition対象の要素 </div> </ViewTransition> )} </> );}ここで重要なのは、setShowItem の呼び出しを startTransition で囲んでいる点です。
React はツリーの変化のパターンに応じて、4種類のアニメーションを自動で使い分けます。
| 種類 | 発生条件 |
|---|---|
| enter | <ViewTransition> がツリーに追加される |
| exit | <ViewTransition> がツリーから削除される |
| update | <ViewTransition> の子要素のスタイルやコンテンツが変わる |
| share | 同じ name を持つ <ViewTransition> が別の場所に移動する |
⚠️ アニメーションが発火するのは、Transitionとしてマークされた更新だけです。startTransition / useTransition での更新、Suspense の解決、useDeferredValue からの更新が対象で、通常の setState は即座に画面へ反映され、アニメーションは起きません。
最初これを知らずに素の setShowItem(true) で試してしまい、「あれ、アニメーションしないぞ」と少しハマりました。<ViewTransition> で囲むだけでなく、更新側も startTransition にする必要がある点は忘れがちです。
デフォルトではクロスフェードでアニメーションしますが、<ViewTransition> に enter / exit / update / share の props で view-transition class を渡すか、onEnter / onExit などのイベントで Web Animations API を直接叩くことで自由にカスタマイズできます。
「同じ状態更新でも、操作によって違うアニメーションを出したい」というケースがあります。
カルーセルで「次へ」を押したときは右から、「前へ」を押したときは左からスライドインさせたい、というのが典型例です。
こういうときに使うのが addTransitionType です。startTransition の中で呼び出すと、そのTransitionに種類(タグ)を付けられます。
const slides = ["🍎 りんご", "🍊 みかん", "🍇 ぶどう", "🍑 もも"];function previousSlide() { startTransition(() => { addTransitionType("previous"); // [!code highlight] setSelected((c) => (c > 0 ? c - 1 : slides.length - 1)); });}function nextSlide() { startTransition(() => { addTransitionType("next"); // [!code highlight] setSelected((c) => (c + 1 < slides.length ? c + 1 : 0)); });}付けたタイプは <ViewTransition> の enter / exit に、タイプ名をキーにしたオブジェクトを渡すことで参照できます。
<ViewTransition key={selected} enter={{ default: "none", // [!code highlight] next: "slide-from-right", previous: "slide-from-left", }} exit={{ default: "none", // [!code highlight] next: "slide-to-left", previous: "slide-to-right", }}> <div className="w-60 rounded-lg bg-emerald-500 p-6 text-xl text-white"> {slides[selected]} </div></ViewTransition>⚠️ enter / exit にタイプ別のオブジェクトを渡す場合、default キーは必須です。addTransitionType で指定していないタイプの更新が来たときのフォールバック用クラス("none" や "auto"、任意のクラス名)を明示する必要があり、省略すると次のような型エラーになります。
型 '{ next: "slide-from-right"; previous: "slide-from-left"; }' を型 'ViewTransitionClass | undefined' に割り当てることはできません。 プロパティ 'default' は型 '...' にありませんが、型 'ViewTransitionClassPerType' では必須です。今回のカルーセルは必ず next か previous のどちらかが付くので default が使われる場面はありませんが、型を満たすために "none" を指定しています。
あとは CSS 側で view-transition class ごとにアニメーションを定義すれば、操作方向に応じたスライドが実現できます。
::view-transition-old / ::view-transition-new は Tailwind のユーティリティでは表現できない疑似要素なので、ここだけは Tailwind プロジェクトでも globals.css などにプレーンな CSS として書く必要があります。
::view-transition-new(.slide-from-right) { animation-name: slide-in-right;}::view-transition-old(.slide-to-left) { animation-name: slide-out-left;}@keyframes slide-in-right { from { transform: translateX(100%); opacity: 0; }}@keyframes slide-out-left { to { transform: translateX(-100%); opacity: 0; }}React はタイプをブラウザの view transition type としても要素に付与するため、:active-view-transition-type(...) を使った CSS 側でのスコープ指定も可能です。
同じ setSelected の呼び出しなのに、addTransitionType の1行を足すだけで「次へ」「前へ」を区別できるのは、実装してみるとかなり見通しが良いと感じました。
<ViewTransition> で <Suspense> の境界を囲むと、フォールバックから最終コンテンツへの切り替わりもアニメーション化できます。
<ViewTransition> <Suspense fallback={<Loading />}> <LazyContent /> </Suspense></ViewTransition>公式ブログでは、この組み合わせにおける理想的な挙動として次の3原則が挙げられています。
デフォルトのままだと、フォールバックの表示自体もアニメーションの対象になってしまい、かえって表示が遅く感じられることがあります。これを避けるには update="auto" default="none" を指定します。
import { Suspense, useState, startTransition, use, ViewTransition } from "react";function LazyProfile() { const profile = use(fetchProfile()); return ( <div className="w-60 rounded-lg bg-violet-500 p-6 text-white"> {profile.title} </div> );}function ProfileLoading() { return ( <div className="w-60 rounded-lg bg-gray-300 p-6 text-gray-700"> 読み込み中... </div> );}export default function Component() { const [showProfile, setShowProfile] = useState(false); return ( <> <button className="rounded-sm border px-3 py-1.5" onClick={() => { startTransition(() => { setShowProfile((prev) => !prev); }); }} > {showProfile ? "➖ 隠す" : "➕ プロフィールを表示"} </button> {showProfile && ( <ViewTransition update="auto" default="none"> {/* [!code highlight] */} <Suspense fallback={<ProfileLoading />}> <LazyProfile /> </Suspense> </ViewTransition> )} </> );}default="none" で enter / exit の既定アニメーションを無効化しつつ、update="auto" でフォールバック→本体の差し替え(update)だけはアニメーションさせる、という指定です。
これで「ボタンを押した瞬間はフォールバックが即表示され、データが揃ったタイミングだけ自然に切り替わる」という、原則どおりの挙動になります。
画像やフォントの読み込み待ちも同じ仕組みで扱えます。<Suspense> の中に <img> や <style>(フォント読み込み用)を置き、外側を <ViewTransition> で囲むだけで、読み込み完了時のアニメーションに乗せられます。
addTransitionType・Suspense との組み合わせの2つを、今回作ったサンドボックスページで実際にコードを書きながら確認しました。
特に「Transitionでマークされた更新だけがアニメーションする」という仕様は、CSS だけでアニメーションを組んでいた頃の感覚のままだと見落としやすいポイントだと感じました。逆に言えば、startTransition を使い分けるだけでアニメーションのオン/オフを制御できるので、状態更新のロジックとアニメーションの実装がかなり分離しやすくなっている印象です。
まだ Safari / Firefox では View Transitions API 自体のサポート状況に差があるので、本番投入する前にターゲットブラウザでの挙動は確認しておいたほうが良さそうです。