最終更新:
Astro View Transitions完全ガイド: ページ遷移アニメーションの実装
Astro 3.0で導入されたView Transitions APIにより、MPAでもSPAのような滑らかなページ遷移が可能になりました。この記事では、基本から高度なカスタマイズまで、実践的な実装方法を解説します。
View Transitions APIとは
View Transitions APIは、ページ遷移時に要素を滑らかにアニメーションさせるブラウザネイティブのAPIです。Astroはこれをラップし、より使いやすくしています。
基本的な有効化
---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>My Site</title>
<!-- View Transitionsを有効化 -->
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
これだけで、すべてのページ遷移が滑らかになります。
仕組み
1. ユーザーがリンクをクリック
↓
2. 次のページをフェッチ
↓
3. 現在のページのスナップショットを撮影
↓
4. 新しいページに切り替え
↓
5. アニメーション実行
基本的なトランジション
デフォルトのフェードアニメーション
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
---
<Layout>
<h1>トップページ</h1>
<p>デフォルトのフェードアニメーション</p>
<a href="/about">Aboutページへ</a>
</Layout>
transition:nameで要素を追跡
---
// src/pages/blog/index.astro
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<Layout>
<h1>ブログ一覧</h1>
<div class="posts">
{posts.map(post => (
<article class="post-card">
<a href={`/blog/${post.slug}`}>
<!-- 画像に transition:name を付けると、遷移先の同じ名前の要素にモーフィング -->
<img
src={post.data.image}
alt={post.data.title}
transition:name={`post-image-${post.slug}`}
/>
<h2 transition:name={`post-title-${post.slug}`}>
{post.data.title}
</h2>
</a>
</article>
))}
</div>
</Layout>
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro';
import { getEntry } from 'astro:content';
const { slug } = Astro.params;
const post = await getEntry('blog', slug);
const { Content } = await post.render();
---
<Layout>
<article class="post">
<!-- 一覧ページと同じ transition:name を使用 -->
<img
src={post.data.image}
alt={post.data.title}
transition:name={`post-image-${slug}`}
/>
<h1 transition:name={`post-title-${slug}`}>
{post.data.title}
</h1>
<Content />
</article>
</Layout>
カスタムアニメーション
transition:animateディレクティブ
---
import { fade, slide } from 'astro:transitions';
---
<Layout>
<!-- フェードイン -->
<header transition:animate={fade({ duration: '0.3s' })}>
<nav>...</nav>
</header>
<!-- スライドイン -->
<main transition:animate={slide({ duration: '0.5s' })}>
<slot />
</main>
<!-- 初期値に戻る(デフォルト動作) -->
<aside transition:animate="initial">
<slot name="sidebar" />
</aside>
<!-- アニメーションなし -->
<footer transition:animate="none">
<p>© 2025</p>
</footer>
</Layout>
カスタムアニメーションの定義
---
// src/components/Hero.astro
---
<section class="hero" transition:animate="scaleUp">
<h1>Welcome</h1>
</section>
<style>
@keyframes scaleUp {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
::view-transition-old(scaleUp) {
animation: scaleUp 0.5s ease-out;
}
::view-transition-new(scaleUp) {
animation: scaleUp 0.5s ease-in;
}
</style>
複雑なカスタムアニメーション
---
// src/pages/products/[id].astro
import Layout from '../../layouts/Layout.astro';
---
<Layout>
<div class="product-detail">
<!-- 商品画像:拡大しながらフェードイン -->
<img
src={product.image}
alt={product.name}
class="product-image"
transition:name={`product-${product.id}`}
/>
<!-- 商品情報:右からスライドイン -->
<div class="product-info" transition:animate="slideFromRight">
<h1>{product.name}</h1>
<p class="price">${product.price}</p>
<p class="description">{product.description}</p>
</div>
</div>
</Layout>
<style>
@keyframes slideFromRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes zoomIn {
from {
transform: scale(0.5);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
::view-transition-old(slideFromRight) {
animation: slideFromRight 0.4s ease-out reverse;
}
::view-transition-new(slideFromRight) {
animation: slideFromRight 0.4s ease-out;
}
.product-image::view-transition-old(product) {
animation: zoomIn 0.6s cubic-bezier(0.68, -0.55, 0.27, 1.55) reverse;
}
.product-image::view-transition-new(product) {
animation: zoomIn 0.6s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
/* レスポンシブ対応:モバイルではアニメーションを簡素化 */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
@media (max-width: 768px) {
@keyframes slideFromRight {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
}
</style>
遷移ライフサイクルイベント
イベントの種類
// astro:before-preparation: 次のページのフェッチ前
// astro:after-preparation: 次のページのフェッチ後
// astro:before-swap: DOM交換前
// astro:after-swap: DOM交換後
// astro:page-load: ページロード完了
ローディングインジケータの実装
---
// src/components/LoadingBar.astro
---
<div id="loading-bar" class="loading-bar"></div>
<script>
const loadingBar = document.getElementById('loading-bar');
// ページ遷移開始時
document.addEventListener('astro:before-preparation', () => {
loadingBar?.classList.add('active');
});
// ページ遷移完了時
document.addEventListener('astro:after-swap', () => {
loadingBar?.classList.remove('active');
});
// エラー時
document.addEventListener('astro:transition-error', () => {
loadingBar?.classList.remove('active');
console.error('Page transition failed');
});
</script>
<style>
.loading-bar {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 3px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
z-index: 9999;
}
.loading-bar.active {
width: 100%;
transition: width 2s ease;
}
</style>
スクロール位置の復元
---
// src/layouts/Layout.astro
---
<script>
// スクロール位置を保存
const scrollPositions = new Map<string, number>();
document.addEventListener('astro:before-preparation', (event) => {
const currentPath = window.location.pathname;
scrollPositions.set(currentPath, window.scrollY);
});
// スクロール位置を復元
document.addEventListener('astro:after-swap', () => {
const newPath = window.location.pathname;
const savedPosition = scrollPositions.get(newPath);
if (savedPosition !== undefined) {
// 復元(ブラウザバック時)
window.scrollTo(0, savedPosition);
} else {
// 新規訪問時は一番上へ
window.scrollTo(0, 0);
}
});
</script>
アナリティクスのページビュー送信
---
// src/layouts/Layout.astro
---
<script>
// Google Analytics 4
document.addEventListener('astro:page-load', () => {
if (typeof gtag !== 'undefined') {
gtag('config', 'GA_MEASUREMENT_ID', {
page_path: window.location.pathname,
});
}
});
// Plausible Analytics
document.addEventListener('astro:page-load', () => {
if (typeof plausible !== 'undefined') {
plausible('pageview');
}
});
</script>
フォーム送信後の処理
---
// src/components/ContactForm.astro
---
<form id="contact-form" method="POST" action="/api/contact">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">送信</button>
</form>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
if (response.ok) {
// 成功時は thank-you ページに遷移
// View Transitions を手動でトリガー
const { navigate } = await import('astro:transitions/client');
navigate('/thank-you');
} else {
alert('送信に失敗しました');
}
} catch (error) {
console.error(error);
alert('エラーが発生しました');
}
});
</script>
クライアントサイドルーティング
プログラマティックなナビゲーション
---
// src/components/SearchResults.astro
---
<div id="search-results"></div>
<script>
import { navigate } from 'astro:transitions/client';
async function performSearch(query: string) {
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
// 検索結果を表示
displayResults(results);
// URLを更新(履歴に追加)
navigate(`/search?q=${query}`);
}
function displayResults(results: any[]) {
const container = document.getElementById('search-results');
if (!container) return;
container.innerHTML = results
.map(
(r) => `
<article>
<h2><a href="/blog/${r.slug}">${r.title}</a></h2>
<p>${r.excerpt}</p>
</article>
`
)
.join('');
}
</script>
条件付きナビゲーション
<script>
import { navigate } from 'astro:transitions/client';
async function handleDelete(id: string) {
const confirmed = confirm('本当に削除しますか?');
if (!confirmed) return;
try {
await fetch(`/api/posts/${id}`, { method: 'DELETE' });
// 削除成功時は一覧ページへ
navigate('/posts', { history: 'replace' });
} catch (error) {
console.error('削除に失敗しました', error);
}
}
</script>
パーシャルハイドレーション対応
Reactコンポーネントとの組み合わせ
---
// src/pages/dashboard.astro
import Layout from '../layouts/Layout.astro';
import InteractiveChart from '../components/InteractiveChart.tsx';
---
<Layout>
<h1 transition:name="dashboard-title">ダッシュボード</h1>
<!--
client:load でハイドレーション
transition:persist で状態を維持
-->
<InteractiveChart
client:load
transition:persist="chart"
data={chartData}
/>
</Layout>
// src/components/InteractiveChart.tsx
import { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2';
interface Props {
data: number[];
}
export default function InteractiveChart({ data }: Props) {
const [chartData, setChartData] = useState(data);
useEffect(() => {
// ページ遷移後もこのコンポーネントは破棄されない
console.log('Chart hydrated, data:', chartData);
}, []);
return (
<div className="chart-container">
<Line
data={{
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
datasets: [{ data: chartData }],
}}
/>
</div>
);
}
transition:persistによる状態維持
---
// src/layouts/Layout.astro
import AudioPlayer from '../components/AudioPlayer.tsx';
---
<!DOCTYPE html>
<html>
<head>
<ViewTransitions />
</head>
<body>
<nav>...</nav>
<main>
<slot />
</main>
<!--
音楽プレーヤーはページ遷移しても再生を継続
transition:persist で DOMを保持
-->
<AudioPlayer
client:load
transition:persist="audio-player"
/>
</body>
</html>
パフォーマンス最適化
プリフェッチ戦略
---
// src/components/BlogPostCard.astro
interface Props {
post: any;
}
const { post } = Astro.props;
---
<article class="post-card">
<!--
data-astro-prefetch でホバー時にプリフェッチ
"hover" | "tap" | "viewport" | "load"
-->
<a
href={`/blog/${post.slug}`}
data-astro-prefetch="hover"
>
<img src={post.image} alt={post.title} />
<h2>{post.title}</h2>
</a>
</article>
カスタムプリフェッチロジック
<script>
import { prefetch } from 'astro:prefetch';
// 重要なページを事前にプリフェッチ
document.addEventListener('DOMContentLoaded', () => {
// 優先度の高いページ
const criticalPages = ['/about', '/contact', '/blog'];
criticalPages.forEach((page) => {
prefetch(page);
});
});
// ビューポートに入ったリンクをプリフェッチ
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const link = entry.target as HTMLAnchorElement;
const href = link.getAttribute('href');
if (href && href.startsWith('/')) {
prefetch(href);
}
}
});
});
document.querySelectorAll('a[href^="/"]').forEach((link) => {
observer.observe(link);
});
</script>
遷移のフォールバック
---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions fallback="animate" />
<!--
fallback="swap": アニメーションをスキップして即座に切り替え
fallback="animate": デフォルトのフェードアニメーション(デフォルト)
fallback="none": View Transitions 非対応ブラウザでは通常のページ遷移
-->
</head>
トラブルシューティング
フラッシュ問題の解決
<style>
/* ページ遷移中にコンテンツがちらつく場合 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.15s;
}
/* 背景色を維持 */
::view-transition-old(root) {
background: white;
}
</style>
JavaScriptエラーの回避
<script>
// ページ遷移時にイベントリスナーをクリーンアップ
let cleanup: (() => void) | null = null;
function initialize() {
const button = document.getElementById('my-button');
const handler = () => {
console.log('Clicked');
};
button?.addEventListener('click', handler);
// クリーンアップ関数を保存
cleanup = () => {
button?.removeEventListener('click', handler);
};
}
// 初回実行
initialize();
// ページ遷移前にクリーンアップ
document.addEventListener('astro:before-preparation', () => {
cleanup?.();
});
// ページ遷移後に再初期化
document.addEventListener('astro:after-swap', () => {
initialize();
});
</script>
まとめ
Astro View Transitionsを活用することで、MPAでありながらSPAのようなUXを実現できます。
重要なポイント
- transition:name: 要素を追跡してモーフィングアニメーション
- transition:animate: カスタムアニメーションの適用
- ライフサイクルイベント: ローディング、アナリティクス、スクロール復元
- transition:persist: クライアントコンポーネントの状態維持
- プリフェッチ: パフォーマンス最適化
View Transitionsはプログレッシブエンハンスメントです。非対応ブラウザでも通常のページ遷移で動作します。