第1回

<ViewTransition>でアニメーション遷移を実際に試す

Reactの実験的なViewTransitionコンポーネントを実際に試す。リストの並び替えや要素の出入りにアニメーションを付ける方法を、Vite環境で動かしながら解説する。

·19分で読める
たける
たける リストを並び替えたりタブを切り替えたりするとき、パッと一瞬で変わるんじゃなくてヌルッとアニメーションさせたいんです。今までその度に別のアニメライブラリを入れてたんですけど、もっと手軽な方法ないですか?
りこ
りこ React 19世代で、本体に `ViewTransition` という実験的なコンポーネントが入った。ブラウザの View Transition API を、Reactの状態更新に合わせて扱いやすくしたもの。まさにその用途。

このシリーズ「React最新ラボ」では、最近のReact界隈の新しい技術を実際に手元で動かしながら検証していく。初回は、アニメーション系で話題の <ViewTransition> を試す。

このシリーズの前提useState / useEffect やコンポーネント分割といった基礎は分かっている中級者向け。React基礎が不安なら先に「React基礎 完全ガイド」を。

<ViewTransition> とは何か

ブラウザには View Transition APIdocument.startViewTransition)という仕組みがある。DOMが切り替わる「前」と「後」のスナップショットを撮り、その差分を自動でアニメーションしてくれるものだ。

ただし素のAPIはDOMを直接操作する前提で、Reactの「状態を変えたら画面が変わる」という宣言的な書き方と噛み合わせるのが面倒だった。<ViewTransition> は、Reactの状態更新に合わせて自動でView Transitionを発火してくれるラッパーになっている。

なつみ
なつみ いいのは、アニメ本体を**CSSの `::view-transition-*` で書ける**こと。JavaScriptでキーフレームを組み立てなくても、見た目の調整はCSS側に寄せられる。

試す準備

<ViewTransition>まだ安定版には入っていない。Canary / Experimental チャンネルでだけ使える。試すには実験版を入れる。

npm install react@experimental react-dom@experimental

⚠️ 実験的API:本番では使わない前提。APIは予告なく変わる可能性がある。あくまで「今のうちに触って動きを掴む」ためのもの。

最小例:要素の出入りにアニメを付ける

まずは一番シンプルな「表示・非表示の切り替え」から。

import { ViewTransition, startTransition, useState } from 'react'

export function HelloToggle() {
  const [show, setShow] = useState(false)

  return (
    <>
      <button
        onClick={() => {
          // ★ 状態更新を startTransition で包むのがポイント
          startTransition(() => setShow((v) => !v))
        }}
      >
        切り替え
      </button>

      {show && (
        <ViewTransition>
          <p className="card">こんにちは!</p>
        </ViewTransition>
      )}
    </>
  )
}
たける
たける なんで `setShow` をそのまま呼ばずに `startTransition` で包むんですか?
りこ
りこ View Transition は「トランジションとして実行された更新」だけをアニメ対象にする決まりだから。ただの `setState` では発火しない。`startTransition` / `useTransition` / `` / `useDeferredValue` のどれか経由で起きた更新が対象になる。

ボタンを押すと、<p> が単に出現するのではなく、ブラウザ標準のクロスフェードで現れる。

押す前
押した後(フワッとフェードインしながら出現)

こんにちは!

アニメを自分で決める:enter / exit とCSS

ブラウザ標準のクロスフェードではなく、自分でアニメを決めたいときは、enter(追加時)・exit(削除時)にクラス名を渡し、CSSの ::view-transition-new / ::view-transition-old に書く。

<ViewTransition enter="slide-in" exit="slide-out">
  <p className="card">こんにちは!</p>
</ViewTransition>
@keyframes slideIn {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* enter="slide-in" → 新しく入ってくる側のスナップショット */
::view-transition-new(.slide-in) {
  animation: slideIn 300ms ease;
}
/* exit="slide-out" → 消えていく側のスナップショット */
::view-transition-old(.slide-out) {
  animation: slideIn 300ms ease reverse;
}

enter / exit などのプロパティに渡せる値は3種類。

意味
"auto" ブラウザ標準のクロスフェード
"none" アニメーションしない
"クラス名" ::view-transition-*(.クラス名) のCSSを当てる

主なプロパティ

<ViewTransition> は「どのタイミングでアニメするか」をプロパティで指定する。

prop 動くタイミング
enter この要素が新しく追加されたとき
exit この要素が削除されたとき
update 中身が変わった・レイアウトがずれたとき
share 同じ name を持つ別の <ViewTransition> が出入りしたとき(共有要素遷移)
default 上記が未指定のときのフォールバック

リストの並び替えを滑らかにする(共有要素遷移)

「同じ要素が場所を移動する」アニメは name を付けると実現できる。並び順が変わっても、Reactが同じ name の要素を「同一物」と見なし、位置の移動をアニメしてくれる。

import { ViewTransition, startTransition, useState } from 'react'

const initial = [
  { id: 'a', label: 'りんご' },
  { id: 'b', label: 'みかん' },
  { id: 'c', label: 'ぶどう' },
]

export function SortableList() {
  const [items, setItems] = useState(initial)

  const shuffle = () => {
    startTransition(() => {
      setItems((prev) => [...prev].reverse())
    })
  }

  return (
    <>
      <button onClick={shuffle}>並び替え</button>
      <ul>
        {items.map((item) => (
          // ★ name は同時に存在する中で一意にする
          <ViewTransition key={item.id} name={`fruit-${item.id}`}>
            <li>{item.label}</li>
          </ViewTransition>
        ))}
      </ul>
    </>
  )
}
たける
たける `name` に `fruit-${item.id}` ってわざわざIDを混ぜてるのは?
りこ
りこ 同じ `name` を持つ `` が同時に2つマウントされるとエラーになる。だから要素ごとに一意になるよう名前空間を付ける。固定文字列だと衝突する。

遷移の「向き」でアニメを変える:addTransitionType

「次へ」は左にスライド、「戻る」は右にスライド――のように、同じコンポーネントでも文脈でアニメを変えたいときは addTransitionType() を使う。

import { addTransitionType, startTransition } from 'react'

function goNext() {
  startTransition(() => {
    addTransitionType('nav-forward')
    setPage((p) => p + 1)
  })
}

function goBack() {
  startTransition(() => {
    addTransitionType('nav-back')
    setPage((p) => p - 1)
  })
}

プロパティ側は、型ごとに値を分けたオブジェクトで受ける。

<ViewTransition
  enter={{
    'nav-forward': 'slide-left',
    'nav-back': 'slide-right',
    default: 'none',
  }}
>
  {/* ページ本体 */}
</ViewTransition>

現場で使うときの注意

なつみ
なつみ 動きが派手な分、`prefers-reduced-motion` の人への配慮は必須。アニメを切る一文を入れておく。
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
  • 実験的react@experimental が必要。本番では使わない。APIは変わりうる。
  • enter / exit はトップレベルで<ViewTransition> の直前に別のDOMノードがあると、enter / exit が発火しない。<div><ViewTransition>…</div> のように包むと効かないことがある。
  • 対応ブラウザ:View Transition API は Chromium 系が先行。未対応ブラウザではアニメせず一瞬で切り替わるだけで、壊れはしない(段階的強化)。
📁 第1回の完成コード(並び替えデモ)

フォルダ構成

src/
├── components/
│   └── SortableList.tsx   ← 今回作成
├── index.css              ← ::view-transition-* のCSSを追記
└── App.tsx

SortableList.tsx

import { ViewTransition, startTransition, useState } from 'react'

type Fruit = { id: string; label: string }

const initial: Fruit[] = [
  { id: 'a', label: 'りんご' },
  { id: 'b', label: 'みかん' },
  { id: 'c', label: 'ぶどう' },
]

export const SortableList = () => {
  const [items, setItems] = useState<Fruit[]>(initial)

  const shuffle = () => {
    startTransition(() => {
      setItems((prev) => [...prev].reverse())
    })
  }

  return (
    <div>
      <button onClick={shuffle}>並び替え</button>
      <ul>
        {items.map((item) => (
          <ViewTransition key={item.id} name={`fruit-${item.id}`}>
            <li>{item.label}</li>
          </ViewTransition>
        ))}
      </ul>
    </div>
  )
}

index.css(抜粋)

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

まとめ

  • <ViewTransition> は、ブラウザの View Transition API を Reactの状態更新に繋ぐ実験的コンポーネント
  • アニメの発火には startTransition(または useTransition / <Suspense> / useDeferredValue)が必要。ただの setState では動かない
  • 見た目は ::view-transition-old / ::view-transition-new のCSSで、要素の移動は name の共有要素遷移で
  • 実験的なので本番は避け、prefers-reduced-motion への配慮を忘れずに

次回は、最小のReactフレームワーク Waku を取り上げる。Server Components を一番小さく試して、このサイトと同じ Cloudflare にデプロイするところまでをやる。