基本
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) を呼ぶと、ブラウザは以下の順で進行する。
- レンダリング停止: 画面の更新を一時的に止め、現在の表示状態を「古いスナップショット」として撮る。
view-transition-nameを付けた要素はそれぞれ独立した snapshot に切り出される。 updateCallback実行: あなたが書いた DOM 更新が走る。Promiseを返した場合はその resolve まで待つ。- 新しい snapshot を撮影: 更新後の DOM を再びスナップショットする。
- 疑似要素ツリーを生成:
::view-transitionをルートに、root と各名前ごとに old / new の対が乗る。 - アニメーション: デフォルトは old が fade-out、new が fade-in。
view-transition-name付きの要素は、加えて位置とサイズを transform/width/height で補間される。 - クリーンアップ: アニメが終わると疑似要素ツリーが消え、本物の DOM が見えるようになる。
::view-transition は position: 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が表示される)。
transition.skipTransition()を呼んだときupdateCallbackが reject したとき- 同じドキュメントで新しい transition が start されたとき(前のはスキップされる)
- ドキュメントが
visibility: hiddenや非アクティブ状態にあるとき - 同名の
view-transition-nameが複数存在して名前衝突を起こしたとき
機能検出(フォールバック)
対応していないブラウザでは document.startViewTransition が undefined。
無条件で呼ばずに分岐する。
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 を素直に繋ぐと、進行中の transition が
毎回 skip されてカクつく。進行中フラグで弾くか、last-write-wins でデバウンスする。