View Transitions API完全ガイド2026 - ページ遷移アニメーション、SPA/MPA対応、クロスドキュメント、Next.js統合
View Transitions API完全ガイド2026
View Transitions APIは、ページ遷移やDOM更新時にスムーズなアニメーションを実現する標準APIです。本記事では、基本から応用までを網羅的に解説します。
目次
- View Transitions APIとは
- 基本的な使い方
- SPA(Single Page Application)での使用
- MPA(Multi Page Application)での使用
- クロスドキュメントトランジション
- カスタムアニメーション
- Next.js統合
- パフォーマンス最適化
- 実践パターン
View Transitions APIとは
基本概念
/**
* View Transitions API の特徴
*
* 1. スムーズなページ遷移
* - DOM更新時の自動アニメーション
* - 要素間の位置・サイズ変化を補間
*
* 2. SPA/MPA両対応
* - 同一ドキュメント内の遷移
* - クロスドキュメント遷移(MPA)
*
* 3. カスタマイズ可能
* - CSS でアニメーション制御
* - JavaScript で動的制御
*
* 4. パフォーマンス
* - GPU 加速
* - 自動最適化
*/
// 最もシンプルな例
function updateView() {
document.startViewTransition(() => {
// DOM を更新
document.querySelector('#content').textContent = '新しいコンテンツ'
})
}
ブラウザサポート
// サポート検出
function supportsViewTransitions(): boolean {
return 'startViewTransition' in document
}
// フォールバック付き実装
function safeViewTransition(callback: () => void): void {
if (supportsViewTransitions()) {
document.startViewTransition(callback)
} else {
callback()
}
}
基本的な使い方
シンプルなトランジション
// 基本形
async function simpleTransition() {
const transition = document.startViewTransition(() => {
// DOM 更新処理
document.getElementById('main').innerHTML = `
<h1>新しいページ</h1>
<p>コンテンツが更新されました</p>
`
})
// トランジション完了を待つ
await transition.finished
console.log('トランジション完了')
}
// ready と finished の違い
async function transitionLifecycle() {
const transition = document.startViewTransition(() => {
updateDOM()
})
// ready: 古いスナップショット取得完了
await transition.ready
console.log('アニメーション開始準備完了')
// finished: アニメーション完了
await transition.finished
console.log('アニメーション完了')
}
デフォルトアニメーション
/* ブラウザのデフォルトアニメーション */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: ease;
}
/* 古いビュー(フェードアウト) */
::view-transition-old(root) {
animation-name: -ua-view-transition-fade-out;
}
/* 新しいビュー(フェードイン) */
::view-transition-new(root) {
animation-name: -ua-view-transition-fade-in;
}
カスタムアニメーション
/* スライドトランジション */
@keyframes slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
::view-transition-old(root) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in 0.3s ease-out;
}
SPA(Single Page Application)での使用
ルーティングとの統合
// シンプルなルーター
class ViewTransitionRouter {
private routes = new Map<string, () => void>()
register(path: string, handler: () => void): void {
this.routes.set(path, handler)
}
async navigate(path: string): Promise<void> {
const handler = this.routes.get(path)
if (!handler) {
console.error(`Route not found: ${path}`)
return
}
// View Transition を使用してナビゲート
const transition = document.startViewTransition(() => {
handler()
// URL 更新
history.pushState(null, '', path)
})
await transition.finished
}
}
// 使用例
const router = new ViewTransitionRouter()
router.register('/', () => {
document.getElementById('app')!.innerHTML = '<h1>ホーム</h1>'
})
router.register('/about', () => {
document.getElementById('app')!.innerHTML = '<h1>About</h1>'
})
// ナビゲーション
document.querySelectorAll('a[data-link]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault()
const href = (e.currentTarget as HTMLAnchorElement).getAttribute('href')!
router.navigate(href)
})
})
名前付きトランジション
// 特定の要素をアニメーション
function setupNamedTransition() {
const items = document.querySelectorAll('.item')
items.forEach((item, index) => {
// view-transition-name を動的に設定
;(item as HTMLElement).style.viewTransitionName = `item-${index}`
})
// トランジション実行
document.startViewTransition(() => {
// アイテムを並び替え
const container = document.querySelector('.container')!
const shuffled = Array.from(items).sort(() => Math.random() - 0.5)
shuffled.forEach(item => container.appendChild(item))
})
}
/* 各アイテムに個別のアニメーション */
::view-transition-old(item-0),
::view-transition-new(item-0) {
animation-duration: 0.5s;
}
/* 位置とサイズの変化を自動補間 */
::view-transition-group(item-0) {
animation-timing-function: ease-in-out;
}
リスト遷移
class AnimatedList {
constructor(private container: HTMLElement) {}
async addItem(content: string): Promise<void> {
const transition = document.startViewTransition(() => {
const item = document.createElement('div')
item.className = 'list-item'
item.textContent = content
// 一意の view-transition-name を設定
item.style.viewTransitionName = `item-${Date.now()}`
this.container.appendChild(item)
})
await transition.finished
}
async removeItem(item: HTMLElement): Promise<void> {
const transition = document.startViewTransition(() => {
item.remove()
})
await transition.finished
}
async reorder(newOrder: HTMLElement[]): Promise<void> {
const transition = document.startViewTransition(() => {
newOrder.forEach(item => this.container.appendChild(item))
})
await transition.finished
}
}
// 使用例
const list = new AnimatedList(document.querySelector('.list')!)
// アイテム追加
await list.addItem('新しいアイテム')
// アイテム削除
const item = document.querySelector('.list-item')!
await list.removeItem(item as HTMLElement)
/* リストアイテムのトランジション */
.list-item {
view-transition-name: auto;
}
::view-transition-old(.list-item),
::view-transition-new(.list-item) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
/* 追加アニメーション */
@keyframes item-add {
from {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 削除アニメーション */
@keyframes item-remove {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
MPA(Multi Page Application)での使用
クロスドキュメントトランジション
<!-- ページ1: index.html -->
<!DOCTYPE html>
<html>
<head>
<meta name="view-transition" content="same-origin" />
<style>
/* トランジションスタイル */
::view-transition-old(root) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in 0.3s ease-out;
}
</style>
</head>
<body>
<img src="hero.jpg" style="view-transition-name: hero-image" />
<a href="detail.html">詳細を見る</a>
</body>
</html>
<!-- ページ2: detail.html -->
<!DOCTYPE html>
<html>
<head>
<meta name="view-transition" content="same-origin" />
<style>
/* 同じトランジションスタイル */
</style>
</head>
<body>
<img src="hero.jpg" style="view-transition-name: hero-image" />
<h1>詳細ページ</h1>
<a href="index.html">戻る</a>
</body>
</html>
プログレッシブエンハンスメント
// クロスドキュメントトランジションのサポート検出
function supportsCrossDocumentTransitions(): boolean {
return (
'startViewTransition' in document &&
'navigation' in window &&
'types' in Navigation.prototype
)
}
// インターセプト実装
if (supportsCrossDocumentTransitions()) {
navigation.addEventListener('navigate', (e) => {
// 外部リンクは除外
if (e.destination.url.startsWith(location.origin)) {
e.intercept({
handler: async () => {
const response = await fetch(e.destination.url)
const html = await response.text()
// View Transition でページ更新
document.startViewTransition(() => {
document.documentElement.innerHTML = html
})
}
})
}
})
}
共有要素のトランジション
// 画像ギャラリーの実装
class Gallery {
setupTransitions(): void {
document.querySelectorAll('.gallery-item').forEach((item, index) => {
const img = item.querySelector('img') as HTMLElement
// 一意の名前を設定
img.style.viewTransitionName = `gallery-${index}`
item.addEventListener('click', () => {
this.openDetail(index)
})
})
}
async openDetail(index: number): Promise<void> {
const transition = document.startViewTransition(async () => {
// 詳細ビューに遷移
const response = await fetch(`/detail/${index}`)
const html = await response.text()
document.body.innerHTML = html
})
await transition.finished
}
}
/* ギャラリー共有要素 */
[style*="view-transition-name: gallery-"] {
contain: layout;
}
::view-transition-group(gallery-0),
::view-transition-group(gallery-1),
::view-transition-group(gallery-2) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
クロスドキュメントトランジション
Navigation APIとの統合
// 完全なクロスドキュメント実装
class CrossDocumentRouter {
constructor() {
if (!('navigation' in window)) {
console.warn('Navigation API not supported')
return
}
this.setupInterception()
}
private setupInterception(): void {
navigation.addEventListener('navigate', (e) => {
// 外部リンク、新しいタブ、フォーム送信は除外
if (
!e.canIntercept ||
e.hashChange ||
e.downloadRequest ||
e.formData
) {
return
}
e.intercept({
handler: () => this.handleNavigation(e.destination.url)
})
})
}
private async handleNavigation(url: string): Promise<void> {
try {
// ページをフェッチ
const response = await fetch(url)
const html = await response.text()
// View Transition でページ更新
const transition = document.startViewTransition(() => {
this.updateDOM(html)
})
await transition.ready
console.log('Transition started')
await transition.finished
console.log('Transition finished')
} catch (error) {
console.error('Navigation failed:', error)
// フォールバック: 通常のナビゲーション
window.location.href = url
}
}
private updateDOM(html: string): void {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// title 更新
document.title = doc.title
// body 更新
document.body = doc.body
// scripts 再実行
this.executeScripts(doc)
}
private executeScripts(doc: Document): void {
const scripts = doc.querySelectorAll('script')
scripts.forEach(script => {
const newScript = document.createElement('script')
newScript.textContent = script.textContent
document.head.appendChild(newScript)
})
}
}
// 初期化
new CrossDocumentRouter()
条件付きトランジション
// ナビゲーション方向によるアニメーション変更
class DirectionalTransition {
private direction: 'forward' | 'back' = 'forward'
navigate(url: string, direction: 'forward' | 'back'): void {
this.direction = direction
// data 属性でアニメーション制御
document.documentElement.dataset.transitionDirection = direction
const transition = document.startViewTransition(async () => {
const html = await this.fetchPage(url)
this.updatePage(html)
})
transition.finished.then(() => {
delete document.documentElement.dataset.transitionDirection
})
}
private async fetchPage(url: string): Promise<string> {
const response = await fetch(url)
return response.text()
}
private updatePage(html: string): void {
document.body.innerHTML = html
}
}
/* 方向によるアニメーション変更 */
/* 前進 */
[data-transition-direction="forward"] {
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out;
}
}
/* 後退 */
[data-transition-direction="back"] {
::view-transition-old(root) {
animation: slide-out-right 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in-left 0.3s ease-out;
}
}
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); }
}
カスタムアニメーション
複雑なトランジション
// カスタムアニメーションコントローラー
class TransitionController {
async executeCustomTransition(
callback: () => void,
config: {
duration?: number
easing?: string
animationType?: 'fade' | 'slide' | 'scale' | 'flip'
} = {}
): Promise<void> {
const {
duration = 300,
easing = 'ease-in-out',
animationType = 'fade'
} = config
// CSS 変数で設定を注入
document.documentElement.style.setProperty('--transition-duration', `${duration}ms`)
document.documentElement.style.setProperty('--transition-easing', easing)
document.documentElement.dataset.transitionType = animationType
const transition = document.startViewTransition(callback)
await transition.finished
// クリーンアップ
delete document.documentElement.dataset.transitionType
}
}
/* CSS変数を使用した動的アニメーション */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: var(--transition-duration, 300ms);
animation-timing-function: var(--transition-easing, ease);
}
/* フェード */
[data-transition-type="fade"] {
::view-transition-old(root) {
animation-name: fade-out;
}
::view-transition-new(root) {
animation-name: fade-in;
}
}
/* スライド */
[data-transition-type="slide"] {
::view-transition-old(root) {
animation-name: slide-out;
}
::view-transition-new(root) {
animation-name: slide-in;
}
}
/* スケール */
[data-transition-type="scale"] {
::view-transition-old(root) {
animation-name: scale-out;
}
::view-transition-new(root) {
animation-name: scale-in;
}
}
/* フリップ */
[data-transition-type="flip"] {
::view-transition-old(root) {
animation-name: flip-out;
}
::view-transition-new(root) {
animation-name: flip-in;
}
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
@keyframes slide-out {
to { transform: translateX(-100%); }
}
@keyframes slide-in {
from { transform: translateX(100%); }
}
@keyframes scale-out {
to { transform: scale(0.8); opacity: 0; }
}
@keyframes scale-in {
from { transform: scale(0.8); opacity: 0; }
}
@keyframes flip-out {
to { transform: rotateY(90deg); opacity: 0; }
}
@keyframes flip-in {
from { transform: rotateY(-90deg); opacity: 0; }
}
モーフィングトランジション
// 要素間のモーフィング
class MorphTransition {
async morph(
fromElement: HTMLElement,
toElement: HTMLElement
): Promise<void> {
// 共通の view-transition-name を設定
const transitionName = `morph-${Date.now()}`
fromElement.style.viewTransitionName = transitionName
toElement.style.viewTransitionName = transitionName
const transition = document.startViewTransition(() => {
// fromElement を非表示
fromElement.style.display = 'none'
// toElement を表示
toElement.style.display = 'block'
})
await transition.finished
// クリーンアップ
fromElement.style.viewTransitionName = ''
toElement.style.viewTransitionName = ''
}
}
// 使用例: サムネイルから詳細画像へ
const morph = new MorphTransition()
const thumbnail = document.querySelector('.thumbnail') as HTMLElement
const fullImage = document.querySelector('.full-image') as HTMLElement
await morph.morph(thumbnail, fullImage)
Next.js統合
App Routerでの実装
// app/components/ViewTransitionLink.tsx
'use client'
import { useRouter } from 'next/navigation'
import { startTransition } from 'react'
interface ViewTransitionLinkProps {
href: string
children: React.ReactNode
className?: string
}
export function ViewTransitionLink({
href,
children,
className
}: ViewTransitionLinkProps) {
const router = useRouter()
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
if (!document.startViewTransition) {
// フォールバック
router.push(href)
return
}
document.startViewTransition(() => {
startTransition(() => {
router.push(href)
})
})
}
return (
<a href={href} onClick={handleClick} className={className}>
{children}
</a>
)
}
カスタムフック
// hooks/useViewTransition.ts
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
export function useViewTransition() {
const router = useRouter()
const navigate = useCallback(
(href: string) => {
if (!document.startViewTransition) {
router.push(href)
return
}
document.startViewTransition(() => {
router.push(href)
})
},
[router]
)
return { navigate }
}
// 使用例
function MyComponent() {
const { navigate } = useViewTransition()
return (
<button onClick={() => navigate('/about')}>
Aboutへ移動
</button>
)
}
レイアウトでの設定
// app/layout.tsx
import './globals.css'
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html lang="ja">
<head>
<meta name="view-transition" content="same-origin" />
</head>
<body>{children}</body>
</html>
)
}
/* globals.css */
/* デフォルトトランジション */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root) {
animation-name: fade-out;
}
::view-transition-new(root) {
animation-name: fade-in;
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
パフォーマンス最適化
スナップショットの最適化
// 大きな要素のスナップショットを回避
class OptimizedTransition {
async transition(callback: () => void): Promise<void> {
// 重い要素を一時的に非表示
const heavyElements = document.querySelectorAll('.heavy-content')
heavyElements.forEach(el => {
(el as HTMLElement).style.viewTransitionName = 'none'
})
const transition = document.startViewTransition(callback)
await transition.finished
// 復元
heavyElements.forEach(el => {
(el as HTMLElement).style.viewTransitionName = ''
})
}
}
条件付きトランジション
// デバイスやネットワーク状態に応じて制御
class AdaptiveTransition {
private shouldUseTransition(): boolean {
// Reduced motion 設定をチェック
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
if (prefersReducedMotion) return false
// デバイス性能をチェック
const connection = (navigator as any).connection
if (connection?.effectiveType === '2g') return false
return true
}
async navigate(callback: () => void): Promise<void> {
if (this.shouldUseTransition()) {
const transition = document.startViewTransition(callback)
await transition.finished
} else {
callback()
}
}
}
メモリ管理
// トランジション中のメモリリーク防止
class SafeTransition {
private activeTransition: ViewTransition | null = null
async execute(callback: () => void): Promise<void> {
// 既存のトランジションをキャンセル
if (this.activeTransition) {
this.activeTransition.skipTransition()
}
this.activeTransition = document.startViewTransition(callback)
try {
await this.activeTransition.finished
} finally {
this.activeTransition = null
}
}
}
実践パターン
ダッシュボード遷移
// ダッシュボードビュー切り替え
class DashboardTransition {
private currentView: string = 'overview'
async switchView(newView: string): Promise<void> {
if (this.currentView === newView) return
// ビューの方向を検出
const views = ['overview', 'analytics', 'settings']
const fromIndex = views.indexOf(this.currentView)
const toIndex = views.indexOf(newView)
const direction = toIndex > fromIndex ? 'forward' : 'back'
document.documentElement.dataset.transitionDirection = direction
const transition = document.startViewTransition(() => {
this.updateView(newView)
})
await transition.finished
this.currentView = newView
delete document.documentElement.dataset.transitionDirection
}
private updateView(view: string): void {
// ビューコンテンツ更新
const container = document.querySelector('.dashboard-content')!
container.innerHTML = this.getViewContent(view)
}
private getViewContent(view: string): string {
// ビュー別コンテンツを返す
return `<div class="view-${view}">...</div>`
}
}
モーダル遷移
// スムーズなモーダル表示
class ModalTransition {
async open(modalId: string): Promise<void> {
const modal = document.getElementById(modalId)!
// view-transition-name 設定
modal.style.viewTransitionName = 'modal'
const transition = document.startViewTransition(() => {
modal.style.display = 'flex'
document.body.style.overflow = 'hidden'
})
await transition.finished
}
async close(modalId: string): Promise<void> {
const modal = document.getElementById(modalId)!
const transition = document.startViewTransition(() => {
modal.style.display = 'none'
document.body.style.overflow = ''
})
await transition.finished
modal.style.viewTransitionName = ''
}
}
/* モーダルトランジション */
::view-transition-old(modal),
::view-transition-new(modal) {
animation-duration: 0.25s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(modal) {
animation-name: modal-fade-out;
}
::view-transition-new(modal) {
animation-name: modal-fade-in;
}
@keyframes modal-fade-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
タブ切り替え
// タブUIのトランジション
class TabTransition {
async switchTab(fromTab: HTMLElement, toTab: HTMLElement): Promise<void> {
// view-transition-name を設定
fromTab.style.viewTransitionName = 'tab-content'
toTab.style.viewTransitionName = 'tab-content'
const transition = document.startViewTransition(() => {
fromTab.hidden = true
toTab.hidden = false
})
await transition.finished
// クリーンアップ
fromTab.style.viewTransitionName = ''
toTab.style.viewTransitionName = ''
}
}
// 使用例
const tabTransition = new TabTransition()
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => {
const tabId = button.getAttribute('data-tab')!
const currentTab = document.querySelector('.tab-content:not([hidden])') as HTMLElement
const nextTab = document.getElementById(tabId) as HTMLElement
tabTransition.switchTab(currentTab, nextTab)
})
})
まとめ
View Transitions APIは、ページ遷移とDOM更新を劇的に改善する強力なツールです。
主要ポイント:
- シンプルなAPI:
startViewTransitionで自動アニメーション - SPA/MPA対応: 両方のアーキテクチャで使用可能
- カスタマイズ可能: CSS で柔軟にアニメーション制御
- パフォーマンス: GPU加速で滑らかな遷移
- プログレッシブエンハンスメント: フォールバック簡単
2026年のベストプラクティス:
- Reduced motion を尊重
- クロスドキュメントトランジションを活用
- Next.jsなどのフレームワークと統合
- パフォーマンス最適化を意識
- 名前付きトランジションで細かく制御
View Transitions APIを活用して、ユーザー体験を次のレベルへ引き上げましょう。