基本

document.startViewTransition() の挙動と、内部で起きていることを正確に押さえる。 ここのモデルが頭に入っていれば、CSS の挙動もパターン集の応用も全部この拡張として読める。

シグネチャ

interface Document {
  startViewTransition(
    updateCallback?: () => void | Promise<void>
  ): ViewTransition

  // Level 2(types 対応ブラウザ)
  startViewTransition(
    init?: { update: () => void | Promise<void>, types?: string[] }
  ): ViewTransition
}

interface ViewTransition {
  readonly updateCallbackDone: Promise<void>
  readonly ready:              Promise<void>
  readonly finished:           Promise<void>
  readonly types?:             Set<string> // Level 2

  skipTransition(): void
}

内部の流れ

startViewTransition(cb) を呼ぶと、ブラウザは以下の順で進行する。

  1. レンダリング停止: 画面の更新を一時的に止め、現在の表示状態を「古いスナップショット」として撮る。 view-transition-name を付けた要素はそれぞれ独立した snapshot に切り出される。
  2. updateCallback 実行: あなたが書いた DOM 更新が走る。Promise を返した場合はその resolve まで待つ
  3. 新しい snapshot を撮影: 更新後の DOM を再びスナップショットする。
  4. 疑似要素ツリーを生成: ::view-transition をルートに、root と各名前ごとに old / new の対が乗る。
  5. アニメーション: デフォルトは old が fade-out、new が fade-in。view-transition-name 付きの要素は、加えて位置とサイズを transform/width/height で補間される。
  6. クリーンアップ: アニメが終わると疑似要素ツリーが消え、本物の DOM が見えるようになる。
疑似要素は本物の DOM の上に「最前面で」かぶさる

::view-transitionposition: fixed; inset: 0 で 実 DOM の上に重なる。だからアニメ中、本物の DOM は見えていない。
イベントもこの疑似要素ツリーが食う形になるので、アニメ中のクリックは効かないと思っておく。

3つの Promise

ViewTransition は3つの Promise を持つ。タイミングが違うので使い分ける。

プロパティresolve するタイミング用途
updateCallbackDone updateCallback が解決した直後 「DOM 更新は終わった、ただしアニメはこれから」のフック
ready 疑似要素ツリーが構築されアニメ開始する直前 JS でアニメを動的にカスタマイズする時の起点
finished すべてのアニメが終わって疑似要素が消えた後 「遷移が完全に終わった」を待ちたい時
const t = document.startViewTransition(() => updateDOM())

t.updateCallbackDone.then(() => { /* DOM は新しい状態 */ })
t.ready.then(() => { /* アニメ直前。WAAPI で上書きできる */ })
t.finished.then(() => { /* 完全に終わった */ })

updateCallback の中身

中で何をしてもよいが、1回だけ DOM を新しい状態に変えるのが基本。 非同期にして fetch などを挟むこともできるが、その間ブラウザは画面を止めるので体感が悪化する。

同期更新(推奨デフォルト)
document.startViewTransition(() => {
  app.setState(nextState)   // React なら flushSync(() => setState(...))
})
非同期にせざるを得ない時はデータを先に取る
// 悪い: updateCallback の中で fetch してしまうと止まっている時間が長い
document.startViewTransition(async () => {
  const data = await fetch(url).then(r => r.json())
  render(data)
})

// 良い: 取ってから transition を始める
const data = await fetch(url).then(r => r.json())
document.startViewTransition(() => render(data))

スキップとキャンセル

以下のいずれかで、現在走っている transition はスキップされる(疑似要素が即座に外れて新DOMが表示される)。

機能検出(フォールバック)

対応していないブラウザでは document.startViewTransitionundefined無条件で呼ばずに分岐する

function withTransition(update) {
  if (!document.startViewTransition) return update()
  return document.startViewTransition(update)
}

シンプル例: ダーク / ライト切替

全画面のクロスフェードはこれだけで成立する。追加 CSS は不要

const toggle = document.querySelector("#theme-toggle")
toggle.addEventListener("click", () => {
  const apply = () => {
    document.documentElement.classList.toggle("dark")
  }
  if (!document.startViewTransition) return apply()
  document.startViewTransition(apply)
})

ここに「クリック位置を起点にした円形展開」を足すと、Chrome のあのおなじみのデモになる。 やり方は パターン集 参照。

複数の startViewTransition を重ねない

連打されるボタンに startViewTransition を素直に繋ぐと、進行中の transition が 毎回 skip されてカクつく。進行中フラグで弾くか、last-write-wins でデバウンスする。