ReactのViewTransitionで画面遷移に動きを付ける方法
React の <ViewTransition> で画面を包み、切り替えを startTransition の中で行うと、ブラウザの View Transition API を使って、古い画面から新しい画面へ動きを付けられます。React が document.startViewTransition を呼ぶので、自分で呼ぶ必要はありません。動きの中身は CSS で書きます。進むときは右から、戻るときは左から入るスライドや、一覧の写真が詳細の大きな写真へ移る動きも作れます。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で開発用に変換し、Chrome for Testing 153(Chromium)を Playwright 1.64.1 で操作しました。動きは、ブラウザが作るアニメーションを document.getAnimations() で読んで確かめています。Firefox や Safari、スマホの実機、ルーター(React Router や Next.js)と組み合わせた形、本番用ビルドの React は試していません。
画面を ViewTransition で包み、startTransition の中で切り替える
React の変更履歴にあるとおり、19.3.0 で <ViewTransition> と addTransitionType が加わりました。検証環境の react 19.3.1 でも、どちらも react から読み込めます。
例として、猫の一覧と、1匹の詳細を切り替える画面を作ります。一覧のボタンを押すと詳細に、詳細の「一覧に戻る」を押すと一覧に戻ります。まずは、一番小さい形です。
import { ViewTransition, startTransition, useState } from 'react';
import type { Cat } from './cats';
import { Detail, List } from './Pages';
// ViewTransition で包み、切り替えを startTransition の中で行うだけの形
export function Basic() {
const [selected, setSelected] = useState<Cat | null>(null);
return (
<ViewTransition>
{selected ? (
<Detail cat={selected} onBack={() => startTransition(() => setSelected(null))} />
) : (
<List onOpen={(cat) => startTransition(() => setSelected(cat))} />
)}
</ViewTransition>
);
}
一覧のボタンを押したとき、document.startViewTransition が呼ばれた回数と、動いたアニメーションを読みました。次は、その出力の抜粋です。一覧と詳細の部品には、後の節で説明する写真の name がすでに付いているため、その行(photo-mike)は省いています。-ua-mix-blend-mode-plus-lighter の行も省いています。
一覧 → 詳細: 呼び出し 1 回, 見出し「ミケ」
::view-transition-group(_t_0_) -ua-view-transition-group-anim-_t_0_ 250ms
::view-transition-group(root) -ua-view-transition-group-anim-root 250ms
::view-transition-new(_t_0_) -ua-view-transition-fade-in 250ms
::view-transition-new(root) -ua-view-transition-fade-in 250ms
::view-transition-old(_t_0_) -ua-view-transition-fade-out 250ms
::view-transition-old(root) -ua-view-transition-fade-out 250ms
startViewTransition は1回呼ばれ、古い画面(old)が消え、新しい画面(new)が現れる 250ms のアニメーションが動きました。_t_0_ は、React が <ViewTransition> に自動で付けた名前です。公式リファレンスの props の説明にも、name を渡さないと、React が重ならない名前を付けるとあります。動きを何も指定しないと、アニメーションが始まる条件の説明にあるとおり、ブラウザの既定の動きになります。MDN の View Transition API の使い方のページにあるとおり、古い画面の不透明度を 1 から 0 に、新しい画面を 0 から 1 にする、クロスフェードです。
公式リファレンスにある、ViewTransition の仕組みの説明によると、React は、アニメーションに加わるときだけ、<ViewTransition> の中の一番近い DOM 要素に view-transition-name を付けます。startViewTransition は React が呼ぶので、自分では呼ばないように、とも書かれています。
startTransition を使わないと、動きは付かない
同じ画面で、setSelected を startTransition で包まずに呼ぶ形も試しました。
一覧 → 詳細: 呼び出し 0 回, 見出し「ミケ」
(アニメーションなし)
詳細 → 一覧: 呼び出し 0 回, 見出し「猫の一覧」
(アニメーションなし)
画面は切り替わりましたが、startViewTransition は呼ばれず、動きも付きませんでした。公式リファレンスの注意点の一覧にも、普通の setState では <ViewTransition> が働かず、Transition(startTransition など)、<Suspense>、useDeferredValue による更新のときだけ働くと書かれています。
進むときと戻るときで、スライドの向きを変える
次は、一覧から詳細へ進むときは右から、戻るときは左から画面が入るようにします。
import { ViewTransition, addTransitionType, startTransition, useState } from 'react';
import type { Cat } from './cats';
import { Detail, List } from './Pages';
import './transitions.css';
export function Gallery() {
const [selected, setSelected] = useState<Cat | null>(null);
function open(cat: Cat) {
startTransition(() => {
addTransitionType('forward');
setSelected(cat);
});
}
function back() {
startTransition(() => {
addTransitionType('back');
setSelected(null);
});
}
return (
<ViewTransition
key={selected ? 'detail' : 'list'}
default="none"
enter={{ forward: 'slide-from-right', back: 'slide-from-left', default: 'none' }}
exit={{ forward: 'slide-to-left', back: 'slide-to-right', default: 'none' }}
>
{selected ? <Detail cat={selected} onBack={back} /> : <List onOpen={open} />}
</ViewTransition>
);
}
ポイントは3つです。1つ目は key です。画面ごとに key を変えると、古い <ViewTransition> が消え、新しいものが入ります。公式リファレンスの、アニメーションが始まる条件の説明によると、入るものには enter、消えるものには exit の動きが使われます。
2つ目は addTransitionType です。startTransition の中で呼ぶと、その切り替えに種類(ここでは forward と back)を付けられます。enter と exit にオブジェクトを渡すと、種類ごとに別のクラス名を使えます。
3つ目は default です。default="none" は、enter と exit 以外の動き(中身が変わったときの update など)を止めます。オブジェクトの中の default: 'none' は、種類が合わないときに使う値です。@types/react 19.3.0 では、オブジェクトの中の default が必須になっていました。抜くと、tsc がエラーを出しました。次は、2つ出たエラーのうちの1つ目です。
typeerror/Gallery.tsx(27,7): error TS2322: Type '{ forward: "slide-from-right"; back: "slide-from-left"; }' is not assignable to type 'ViewTransitionClass | undefined'.
Property 'default' is missing in type '{ forward: "slide-from-right"; back: "slide-from-left"; }' but required in type 'ViewTransitionClassPerType'.
動きは CSS に書きます。React が付けたクラス名は、::view-transition-new(.クラス名) のように、擬似要素のかっこの中に . を付けて指定します。書き方は公式リファレンスの、View Transition のスタイルの付け方の説明にあります。
@keyframes slide-in-from-right { from { transform: translateX(100%); } }
@keyframes slide-in-from-left { from { transform: translateX(-100%); } }
@keyframes slide-out-to-left { to { transform: translateX(-100%); } }
@keyframes slide-out-to-right { to { transform: translateX(100%); } }
/* 進むとき:新しい画面は右から入り、古い画面は左へ出る */
::view-transition-new(.slide-from-right) { animation: 300ms ease-out both slide-in-from-right; }
::view-transition-old(.slide-to-left) { animation: 300ms ease-out both slide-out-to-left; }
/* 戻るとき:向きを逆にする */
::view-transition-new(.slide-from-left) { animation: 300ms ease-out both slide-in-from-left; }
::view-transition-old(.slide-to-right) { animation: 300ms ease-out both slide-out-to-right; }
ボタンを押して、動いたアニメーションを読んだ結果の抜粋です。
一覧 → 詳細: 呼び出し 1 回, 見出し「ミケ」
::view-transition-new(_t_1_) slide-in-from-right 300ms
::view-transition-old(_t_0_) slide-out-to-left 300ms
詳細 → 一覧: 呼び出し 1 回, 見出し「猫の一覧」
::view-transition-new(_t_2_) slide-in-from-left 300ms
::view-transition-old(_t_1_) slide-out-to-right 300ms
進むときは、新しい画面に slide-in-from-right、古い画面に slide-out-to-left が使われました。戻るときは、どちらも逆向きになりました。key を変えたので、画面ごとに別の名前(_t_0_、_t_1_、_t_2_)が付いています。
一覧と詳細で、同じ写真をつなぐ
一覧の小さな写真と、詳細の大きな写真に同じ name を付けると、写真が場所と大きさを変えながら移る動きになります。公式リファレンスの、要素を共有するアニメーションの説明では、消える側と入る側に同じ名前の組があると、share の動きになると説明しています。
function Photo({ cat, size }: { cat: Cat; size: 'small' | 'large' }) {
// 一覧と詳細で同じ name を付けると、写真が場所を移るように動く
return (
<ViewTransition name={`photo-${cat.id}`} share="photo-morph">
<div className={`photo photo--${size}`} style={{ background: cat.color }} />
</ViewTransition>
);
}
List と Detail の両方が、この Photo を使います。公式リファレンスの、同じ名前が2つあるときのエラーの説明によると、同じ name の <ViewTransition> が同時に2つあるとエラーになります。一覧には写真が3枚同時に並ぶので、猫ごとに photo-mike のような別の名前にしています。

戻るときに写真が隠れないように、z-index を上げる
share="photo-morph" で写真に付けたクラス名を使い、CSS で写真の z-index を上げています。
/* 場所を移る写真を、入れ替わる画面より手前に出す */
::view-transition-group(.photo-morph) { z-index: 1; }
この指定がないと、戻るときに、写真が途中で見えなくなりました。検証では、この指定を z-index: auto で打ち消した場合と比べています。動きを止めて時間ごとに撮った画面で、写真の色の画素を数えた結果のうち、戻るときの行です。
--- z-index を付けない場合
詳細 → 一覧: 写真の group の z-index auto / 写真の色の画素数 50ms: 34334, 100ms: 1880, 150ms: 0, 200ms: 0 / 終わった後: 3764
--- z-index: 1 を付けた場合
詳細 → 一覧: 写真の group の z-index 1 / 写真の色の画素数 50ms: 48598, 100ms: 15710, 150ms: 5714, 200ms: 4751 / 終わった後: 3764
z-index を付けないと、150ms と 200ms の画面で写真の色の画素数が 0 になり、終わった後には一覧の写真が見えていました。写真が、左から入ってくる一覧の画面より下に描かれ、背景色のある一覧のボタンに隠れたと考えられます。z-index: 1 を付けると、途中でも写真が見えたまま、一覧の枠へ縮みました。進むときは、どちらでも写真の色の画素数は同じで、隠れませんでした。
ViewTransition の上に div があると、スライドしない
入れ替わる部分の一番上に div を置き、その下に <ViewTransition> を置く形も試しました。key は div に付けています。次のコードは、検証に使った Wrapped から抜き出したもので、中身は省いています。出力は、一覧から詳細へ進んだときのものです。
<div key={selected ? 'detail' : 'list'}>
<ViewTransition default="none" enter="slide-from-right" exit="slide-to-left">
{/* 一覧か詳細 */}
</ViewTransition>
</div>
--- wrapped
一覧 → 詳細: 呼び出し 1 回, 見出し「ミケ」
::view-transition-group(photo-mike) -ua-view-transition-group-anim-photo-mike 250ms
::view-transition-new(photo-mike) -ua-mix-blend-mode-plus-lighter 250ms
::view-transition-new(photo-mike) -ua-view-transition-fade-in 250ms
::view-transition-old(photo-mike) -ua-mix-blend-mode-plus-lighter 250ms
::view-transition-old(photo-mike) -ua-view-transition-fade-out 250ms
::view-transition-old(root) -ua-view-transition-fade-out 250ms
写真の share は動きましたが、画面のスライド(slide-in-from-right)は使われませんでした。公式リファレンスの、enter と exit が働く位置についての注意に、enter と exit は、<ViewTransition> がどの DOM 要素よりも上にあるときだけ働くと書かれています。入れ替わる部分の一番外側を <ViewTransition> にし、key もそこに付けます。
動きを減らす設定と、対応していないブラウザ
公式リファレンスの、prefers-reduced-motion についての注意によると、React は、動きを減らす設定(prefers-reduced-motion)のときにも、アニメーションを自動では止めません。そこで、CSS で止めておきます。
/* 動きを減らす設定の人には、アニメーションを付けない */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Playwright で動きを減らす設定にして開くと、startViewTransition は呼ばれましたが、動くアニメーションはありませんでした。画面は切り替わりました。次は、一覧から詳細へ進んだときの出力です。
--- gallery (reducedMotion: reduce)
一覧 → 詳細: 呼び出し 1 回, 見出し「ミケ」
(アニメーションなし)
document.startViewTransition がないブラウザの代わりとして、画面を開いた後、ボタンを押す前に Document.prototype.startViewTransition を消す形も試しました。画面は切り替わり、アニメーションはなく、エラーも出ませんでした。次は、一覧から詳細へ進んだときの出力です。
--- gallery (startViewTransition なし)
startViewTransition あり: false
一覧 → 詳細: 呼び出し 0 回, 見出し「ミケ」
(アニメーションなし)
<ViewTransition> を、入れ替わる部分の一番外側に置き、切り替えを startTransition の中で行います。進む・戻るで動きを変えるときは addTransitionType で種類を付け、enter と exit に種類ごとのクラス名を渡して、::view-transition-new(.クラス名) などに CSS を書きます。場所を移る写真には同じ name を付け、画面の下に隠れないように z-index を上げておきます。





