SPA で使う(同一ドキュメント遷移)
ルーター・状態管理ライブラリと組み合わせて View Transition を発火させるパターン集。
共通する勘所は 「DOM が新しい状態にコミットされる瞬間を startViewTransition の中に閉じ込める」こと。
素の JS(ルーター無し)
history API + DOM 入れ替えだけのミニルーター。startViewTransition でラップするだけ。
async function navigate(url) {
const res = await fetch(url)
const html = await res.text()
const doc = new DOMParser().parseFromString(html, "text/html")
const next = doc.querySelector("#main").innerHTML
const apply = () => {
document.querySelector("#main").innerHTML = next
history.pushState({}, "", url)
}
if (!document.startViewTransition) return apply()
document.startViewTransition(apply)
}
fetch は外で済ませて、startViewTransition の中は同期更新だけにしているのがポイント。
これでスナップショット直後の凍結時間を最小化できる。
React
React の setState は非同期にバッチされる。
そのまま startViewTransition の中で setState しても、
ブラウザが新スナップショットを撮る時点で DOM が変わっておらず、transition が空振る。
対策は react-dom の flushSync でその場でコミットさせる。
import { flushSync } from "react-dom"
function withViewTransition(update: () => void) {
if (!document.startViewTransition) return update()
document.startViewTransition(() => flushSync(update))
}
// 使用例
<button onClick={() => withViewTransition(() => setRoute("detail"))}>
詳細を見る
</button>
flushSync は React の Concurrent な振る舞い(時間の細切れ・割り込み)を一旦止める。
頻繁にやるとパフォーマンスが落ちる。ナビゲーションの瞬間など、明示的な遷移時だけ使う。
Suspense と遷移
遷移先のコンポーネントが Suspense で fetch している場合、startViewTransition 直後に
DOM がローディング表示になってしまうことがある。
遷移前にデータをプリフェッチして、コミット時点でデータが揃っている状態を作るのが王道。
Tanstack Router / React Router 等のルーターは「loader」相当の機構を持っているのでそれに乗る。
Vue
Vue は nextTick でフラッシュタイミングを取れる。startViewTransition の中で
ステートを変更し、nextTick を await すれば DOM 反映後に transition が走る。
import { nextTick } from "vue"
async function withTransition(update) {
if (!document.startViewTransition) {
update()
await nextTick()
return
}
await document.startViewTransition(async () => {
update()
await nextTick()
}).updateCallbackDone
}
Svelte / SolidJS
どちらも更新が同期的にDOMへ反映されるので、素直に書ける。
// Svelte
function navigate(next) {
if (!document.startViewTransition) return route.set(next)
document.startViewTransition(() => route.set(next))
}
ルーター連携
Next.js (App Router)
Next.js の useRouter().push() は内部で React Server Components のフェッチが走るため、
startViewTransition でラップする時はナビゲーションの完了を待つ作りにする。
"use client"
import { useRouter } from "next/navigation"
export const TransitionLink = ({ href, children }: any) => {
const router = useRouter()
return (
<a
href={href}
onClick={(e) => {
e.preventDefault()
if (!document.startViewTransition) return router.push(href)
document.startViewTransition(() => router.push(href))
}}
>{children}</a>
)
}
React Router (v7 / Remix)
v7 系には unstable_viewTransition(または安定版の同等オプション)が組み込まれている。
<Link viewTransition> または useNavigate(..., { viewTransition: true }) を使えば、
ライブラリ側が startViewTransition + flushSync を面倒見てくれる。
Astro
Astro はマルチページ前提なので、MPA 版を使う。
<ClientRouter />(旧 <ViewTransitions />)を <head> に入れると
Astro が自動で SPA 風の同一ドキュメント遷移に置き換えてくれる。
動的に view-transition-name を付けて hero 遷移
リスト → 詳細の遷移で、サムネイルがそのまま大きな画像に変形する演出。 遷移前のサムネイルと遷移後のヒーロー画像に同じ name を当てるのが要。
// 一覧側
<img
src={item.thumb}
style={{ viewTransitionName: `card-${item.id}` }}
/>
// 詳細側(同じ id)
<img
src={item.hero}
style={{ viewTransitionName: `card-${item.id}` }}
/>
遷移時に同じ name のペアが見つかると、ブラウザは 位置・サイズの補間を勝手にやってくれる。
この時、CSS で ::view-transition-group(card-3) { animation-duration: 400ms; } のように
微調整するのが普通。
一覧から詳細に遷移する瞬間、遷移後の DOMでも同じ name が一覧に残っていると衝突する。
典型的には「詳細ビューを表示すると同時に一覧ビューを display: none にする」のように、
確実に片方だけが存在する状態を作る。
types で進む / 戻るを分岐(Level 2)
対応ブラウザなら、ナビゲーション方向に応じて別のアニメを出せる。
function navigate(url, direction = "forward") {
document.startViewTransition({
update: () => doNavigate(url),
types: [direction],
})
}
後は :active-view-transition-type() で
CSS を分岐すればいい。