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-domflushSyncその場でコミットさせる。

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 は同期 commit を強制するので副作用に注意

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 を当てるのが要。

React 例(idベースで動的に名前を付ける)
// 一覧側
<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; } のように 微調整するのが普通。

同じ name は同時に複数存在できない

一覧から詳細に遷移する瞬間、遷移後の DOMでも同じ name が一覧に残っていると衝突する。
典型的には「詳細ビューを表示すると同時に一覧ビューを display: none にする」のように、 確実に片方だけが存在する状態を作る。

types で進む / 戻るを分岐(Level 2)

対応ブラウザなら、ナビゲーション方向に応じて別のアニメを出せる。

function navigate(url, direction = "forward") {
  document.startViewTransition({
    update: () => doNavigate(url),
    types: [direction],
  })
}

後は :active-view-transition-type() で CSS を分岐すればいい。