Astroで構築したサイトに「SPAのような滑らかで心地よいページ遷移を実装したい」と思ったことはありませんか?
静的サイトとしての圧倒的な軽さを維持したままリッチなUXを実現できる仕組みとして、AstroのView Transitions APIは非常に強力です。しかし、いざ実務で導入しようとすると、「Astro 6になってコンポーネント名や書き方が変わってどうすればいいか分からない」「2ページ目以降に遷移するとJavaScriptやGA4の計測タグが動かなくなる」「フォーム送信やURLパラメータが付いた途端にアニメーションが崩れてしまう」といった、数々の実務的な壁にぶつかりがちです。
せっかくの素晴らしい機能も、バグやレイアウト崩れが残ったままでは本番サイトで採用できませんよね。
そこでこの記事では、Astro 6の最新仕様に完全準拠したView Transitions(ClientRouter)の正しい実装手順から、現場で必ず役立つトラブルシューティング、さらにはSEOやパフォーマンスへの影響までをシニアエンジニアの視点で徹底的に解説します。
AstroのView Transitions APIとは?MPAでSPA風UXを実現する仕組みとメリット
View Transitions APIは、ページ遷移前後のDOMスナップショットを比較し、その差分をブラウザネイティブに自動アニメーションさせるWeb標準APIです。Astroはこのブラウザ機能を<ClientRouter />コンポーネントでラップし、MPA(マルチページアプリケーション)の構成を一切変更せずに、SPAのような滑らかな画面遷移を実現します。JSフレームワークのランタイムやVirtual DOMは不要で、静的サイトのままUXを引き上げられる点がAstroの最大の強みです。

View Transitions APIの仕組みとAstroのMPA構成で動く理由
View Transitions APIは「遷移前のページをスナップショットとして撮影し、遷移後のページとクロスフェード(または任意のCSSアニメーション)で合成する」という単純な仕組みで動作します。これはJavaScriptフレームワークに依存しないブラウザレベルの標準機能であり、Astroが独自に発明したものではありません。
ブラウザのView Transitionsには2種類のモードがあります。
| 種類 | 用途 | 主なAPI |
|---|---|---|
| Same-document(同一ドキュメント) | SPA内のDOM更新を伴う遷移 | document.startViewTransition() |
| Cross-document(異ドキュメント) | <a>タグによる別HTMLへのフルナビゲーション | @view-transition(CSS at-rule)、pageswap/pagerevealイベント |
Astroの<ClientRouter />は、このうちブラウザのCross-document対応を待つことなく、内部的に以下の処理でMPA上にSPA的な遷移を再現しています。
<a>タグのクリックや履歴の戻る/進む操作をルーターがインターセプトする- 遷移先ページのHTMLを
fetchで取得する - 取得したHTMLの
<head>と<body>を、現在のドキュメントに対して差分スワップする - そのDOMスワップ処理を
document.startViewTransition()でラップし、Same-documentのView Transitions APIとしてアニメーションさせる
つまりAstroの<ClientRouter />は、ブラウザのネイティブCross-document機能に依存しない自前のクライアントサイドルーターです。これにより、Cross-document View Transitionsの対応が遅れているブラウザでも一貫した遷移アニメーションを提供できます。
なお、Cross-document View Transitions自体もブラウザの実装が進んでおり、Chrome 126以降・Safari 18.2以降で標準対応、Firefox も144以降で対応が完了しています。Astroの<ClientRouter />を導入済みのプロジェクトでは、この恩恵もそのまま受けられるため、「Astroを使うか、ブラウザネイティブのCSSだけで済ませるか」は今後の選定ポイントになりますが、transition:persistによるステート維持やライフサイクルイベントなど、ネイティブAPIにはないAstro独自の拡張機能が必要な場合は、引き続き<ClientRouter />の採用が妥当です。
Next.js・Nuxt・SvelteKitとの違いから見るAstroならではのメリット
Next.jsやNuxt、SvelteKitでページ遷移アニメーションを実装する場合、クライアントサイドのJSフレームワークランタイムとそのルーティング機構が前提条件になります。一方Astroは、Islands アーキテクチャによって「デフォルトでJSゼロ」を維持したまま、軽量なクライアントルーターだけを追加で読み込む構成です。
| 観点 | Next.js / Nuxt / SvelteKit | Astro |
|---|---|---|
| 遷移アニメーションの実現方法 | Framer Motion、Vueの<Transition>、独自トランジションAPIなど、フレームワーク依存のライブラリが必要になることが多い | ブラウザ標準のView Transitions APIをそのまま利用 |
| 前提となるJS | クライアントサイドルーティングのためフレームワーク本体の実行が必須 | デフォルトはゼロJS。<ClientRouter />を追加した分だけJSが増える |
| 状態維持の方法 | フレームワークのグローバルステート(Reduxやpiniaなど)で管理 | transition:persistでDOM要素単位の永続化が可能 |
| 採用の前提 | 基本的にSPA/ハイブリッドレンダリングのフレームワーク全体を選定する必要がある | 既存の静的サイトやMPA構成に後付けで導入できる |
この違いから生まれるAstro特有のメリットは次の3点です。
- バンドルサイズへの影響が小さい
フレームワークのクライアントランタイムを必要としないため、ページ遷移アニメーションのためだけに数十KB〜数百KBのJSを追加で読み込む必要がありません。 - 既存の静的サイトに後付けしやすい
レイアウトファイルに<ClientRouter />を1行追加するだけで導入できるため、すでに本番運用しているAstroサイトへの段階的な導入が容易です。 - フレームワーク混在環境でも一貫した体験を提供できる
React・Vue・Svelteなど複数のUIフレームワークをIslandsとして併用しているAstroプロジェクトでも、<ClientRouter />はフレームワークに依存しないため、どのIslandを使っていても同じ遷移体験を統一して提供できます。
Astro 6の<ClientRouter />とは?旧<ViewTransitions />との違い
Astro 6.0では<ViewTransitions />コンポーネントが完全に削除されました。 今後は<ClientRouter />への移行が必須です。両者は機能的には同一のコンポーネントであり、単純なリネームですが、Astro 5系からのアップグレード時にビルドエラーの原因になるため注意が必要です。
---
// src/layouts/BaseLayout.astro
- import { ViewTransitions } from 'astro:transitions';
+ import { ClientRouter } from 'astro:transitions';
---
<html lang="ja">
<head>
- <ViewTransitions />
+ <ClientRouter />
</head>
<body>
<slot />
</body>
</html>
Astro 5系では<ViewTransitions />は非推奨(deprecated)として警告付きで動作していましたが、Astro 6.0以降はastro:transitionsからViewTransitionsという名前のエクスポート自体が存在しません。 古いチュートリアルやサンプルコードをコピーすると以下のようなビルドエラーが発生するため、コードを参照する際はAstroのバージョンを必ず確認してください。
[ERROR] Module '"astro:transitions"' has no exported member 'ViewTransitions'.
また、Astro 6.1では<ClientRouter />自体にもモバイル向けの改善が加えられています。iOS Safariのスワイプジェスチャーのようにブラウザ自身が既にネイティブの視覚的トランジションを提供している場合、Astro側のアニメーションを自動的にスキップするようになりました。これにより、従来発生していた「ブラウザのスワイプアニメーション」と「Astroのフェードアニメーション」が二重に走ってチラつく不具合が解消されています。バージョンアップによる挙動変化として把握しておく価値があります。
【Astro 6対応】最小構成で始めるView Transitionsの導入手順と基本コード
Astro 6で<ClientRouter />を導入する最短手順は、共通レイアウトファイルの<head>内に1行追加するだけです。ここでは最小構成のコード例に加え、全ページ一括適用と特定リンクだけを対象外にする制御手法、そして実務で必ず問題になる「スクロール位置の挙動」の制御方法まで、すぐに使える形でまとめます。
<ClientRouter />を使った最小構成の実装手順
導入に必要な作業は次の3ステップのみです。Astro 6では追加のインテグレーションやパッケージインストールは不要で、astro:transitionsは組み込みモジュールとしてすでにAstro本体に含まれています。
- 共通レイアウトファイル(
src/layouts/BaseLayout.astroなど)を開く - フロントマター部分で
astro:transitionsからClientRouterをインポートする <head>タグの内側に<ClientRouter />を配置する
---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>My Astro Site</title>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>
このレイアウトを各ページが共有していれば、これだけでサイト全体のページ遷移がフェードアニメーション付きのクライアントサイド遷移に切り替わります。追加のCSSやJSは一切不要で、transition:animateやtransition:nameを個別に指定しなければ、Astroが要素の類似性をもとに自動でデフォルトアニメーションを適用します。
<ClientRouter />が機能するのはサイト内(同一オリジン)のページ遷移に限られます。外部サイトへのリンクや、.pdfなどの非HTMLファイルへのリンクには影響しません。
全ページ一括適用と特定ページだけを対象外にする(有効・無効化)制御手法
View Transitionsの適用範囲には「サイト全体に適用する」方法と「特定のページ・リンクだけに適用する/除外する」方法の2系統があり、用途に応じて使い分けます。
サイト全体に一括適用する場合は、前述の通り共通レイアウトに<ClientRouter />を1つ配置するだけで完結します。一方、特定のページ間だけにView Transitionsを限定したい場合は、共通レイアウトには配置せず、対象ページそれぞれの<head>に個別に<ClientRouter />を記述します。このとき注意すべき点は、遷移元・遷移先の両方のページに<ClientRouter />が存在していなければ、クライアントサイド遷移は発生しないということです。片方のページにしか配置されていない場合、ブラウザは通常のフルページ遷移にフォールバックします。
特定のリンクだけをクライアントサイド遷移の対象外にしたい場合は、data-astro-reload属性を<a>タグまたは<form>タグに付与します。これはルーター全体の設定を変えずに、リンク単位で個別にフルページリロードへフォールバックさせるもっとも簡単な方法です。
<!-- このリンクだけは常にフルページリロードになる -->
<a href="/legal/privacy-policy" data-astro-reload>プライバシーポリシー</a>
<!-- PDFなど非HTMLファイルへのリンクにも明示しておくと安全 -->
<a href="/files/whitepaper.pdf" data-astro-reload>資料をダウンロード</a>
履歴の扱いを個別に制御したい場合は、data-astro-history属性でauto・push・replaceのいずれかを指定できます。例えばモーダル的に開く一覧ページへの遷移など、ブラウザ履歴に新しいエントリを残したくない場合はreplaceを指定します。
<a href="/search?tag=astro" data-astro-history="replace">タグで絞り込む</a>実務上の落とし穴として、現在表示中のページへ向かう<a>タグをクリックした場合でも、<ClientRouter />はトランジションを再生してしまう挙動が報告されています(withastro/astro#14034)。astro:before-preparationイベントでpreventDefault()を呼んでも、これはクライアントサイド遷移を無効化するだけで、ナビゲーション自体は依然として発生してしまいます。同一ページへのリンクをアニメーションさせたくない場合は、クリックハンドラ側でwindow.location.pathnameと遷移先のパスを事前に比較し、一致する場合はそもそも<a>要素のクリックを処理しないようにするなど、アプリケーション側でのガード処理が必要です。
ページ遷移時のスクロール位置(トップへ戻る・現在の位置を保持)を制御する設定
スクロール位置の挙動は、ナビゲーションの種類によってAstroが自動的に判定しています。新しいページへ<a>タグでリンク遷移する(push)場合はページ上部にスクロールし、ブラウザの戻る・進むボタンによる履歴遷移(traverse)の場合は、Astroの内部スクロール復元処理によって遷移前のスクロール位置が自動的に復元されます。これは公式ドキュメントで明記されているスワップ処理のステップの一部であり、開発者が個別に実装する必要はありません。
ただし、この自動復元にはグローバル設定でオン・オフを切り替えるオプションが存在しない点に注意してください。サイト全体にscroll-behavior: smooth;を指定していると、履歴の戻る・進む操作のたびにスクロールがアニメーションしてしまい、ユーザー体験として不自然になるケースがあります。この場合はastro:before-swapイベントが提供するnavigationTypeプロパティ(push / replace / traverseのいずれか)を使い、履歴遷移のときだけ一時的にスムーススクロールを無効化するという実装が現実的な解決策です。
<script is:inline>
document.addEventListener('astro:before-swap', (event) => {
const isHistoryTraversal = event.navigationType === 'traverse';
if (isHistoryTraversal) {
// 戻る・進む操作のときだけ、一瞬だけ smooth スクロールを止めて瞬間移動にする
document.documentElement.style.scrollBehavior = 'auto';
}
});
document.addEventListener('astro:after-swap', () => {
// 復元が終わったら元の設定に戻す
document.documentElement.style.scrollBehavior = '';
});
</script>
また、Lenisなどのスムーススクロールライブラリを併用している場合、ライブラリ側がwindow.scrollYを直接書き換えることでAstroのスクロール復元処理と競合し、Firefoxを中心に挙動が崩れる事例が報告されています(withastro/astro#12725)。サードパーティのスクロールライブラリを導入する際は、astro:before-preparation・astro:before-swap・astro:after-swapの各イベントでライブラリ側のスクロール監視を一時停止する処理を組み込んでおくと、不要な競合を避けられます。
サイドバーや長いナビゲーションリストなど、特定の要素単体でスクロール位置を保持したい場合は、ページ全体のスクロール復元とは別の問題になります。この場合は後述するtransition:persistを使い、要素そのものをDOMごと次のページに引き継ぐ実装が確実です。
Astroのページ遷移アニメーションをカスタマイズする方法
デフォルトのフェードアニメーションだけでは物足りない場合、Astroはtransition:nameとtransition:animateという2つのディレクティブで要素単位の細かいアニメーション制御を可能にしています。ただし、画像の歪みやレイアウト崩れは実装時に頻発するため、CSSの注意点も合わせて押さえておく必要があります。
ヘッダー・ロゴ・サムネイル画像を自然にアニメーションさせる記述例
共通の要素を自然にアニメーションさせるには、遷移元・遷移先の両ページで同じtransition:nameの値を指定することが基本です。Astroは要素の種類とDOM上の位置からペアをある程度自動推測しますが、レイアウト構造が異なるページ間(一覧ページと詳細ページなど)では自動推測が外れやすいため、明示的に名前を指定するのが確実です。
記事一覧からサムネイル画像と見出しを詳細ページへモーフィングさせる例は次の通りです。
---
// src/pages/index.astro(記事一覧)
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<ul class="grid grid-cols-3 gap-6">
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
<img
src={post.data.thumbnail}
alt={post.data.title}
transition:name={`thumbnail-${post.slug}`}
/>
<h2 transition:name={`title-${post.slug}`}>{post.data.title}</h2>
</a>
</li>
))}
</ul>
---
// src/pages/blog/[slug].astro(記事詳細)
const { post } = Astro.props;
---
<img
src={post.data.thumbnail}
alt={post.data.title}
transition:name={`thumbnail-${post.slug}`}
/>
<h1 transition:name={`title-${post.slug}`}>{post.data.title}</h1>
ここで絶対に守らなければならない制約が1つあります。transition:nameに指定する値は、同一ページ内で重複してはいけません。記事一覧のようにループでカードを描画する場合、すべてのカードに固定文字列(例:”thumbnail“)を指定すると、同じページ内に同名のtransition:nameが複数存在することになり正しく動作しません。上記の例のようにpost.slugなどの一意な識別子を埋め込んで、カードごとに異なる名前を動的生成するのが正しい実装です。
ヘッダーやロゴのように1ページに1つしか存在しない要素であれば、Astroの自動マッチングだけでも機能することが多いですが、レイアウトファイルが複数あるプロジェクトや、ヘッダーの内部構造がページによって微妙に異なる場合は、transition:name="site-logo"のように明示しておくと事故が減ります。
レイアウト崩れを防ぐためのDOM構造とCSSの注意点
View Transitions APIは<img>そのものをアニメーションさせているわけではありません。遷移前後のスナップショット(ラスター画像)を撮影し、その画像を含むボックスのサイズをモーフィングさせているだけです。この仕組みを理解していないと、サムネイルの正方形画像と詳細ページの横長ヒーロー画像をモーフィングさせた際に、画像が一瞬ぐにゃっと歪む不具合に必ず遭遇します。
原因は、ブラウザが生成する::view-transition-old()と::view-transition-new()という擬似要素のデフォルトのobject-fitがfillになっていることです。fillは「アスペクト比を無視してボックスいっぱいに引き伸ばす」設定のため、正方形から横長へボックスの形状が変わると、中の画像も一緒に引き伸ばされて歪んで見えます。
修正方法は、対象のtransition:nameに対応する擬似要素へobject-fit: coverを明示的に指定することです。
/* src/styles/global.css などグローバルCSSに記述 */
::view-transition-old(thumbnail-*),
::view-transition-new(thumbnail-*) {
object-fit: cover;
}
補足:::view-transition-old()/::view-transition-new()の擬似要素は、通常のDOM階層から切り離された独立したツリーとして生成されるため、CSSクラスや属性セレクタで一括指定することができません。transition:nameにpost.slugなどの動的な値を使う一覧ページのカードでは、画像1枚ごとに名前が異なるため、object-fitをすべての画像に確実に当てるには名前を列挙する必要があります。
要素数が多い一覧ページでこれを避けたい場合は、サムネイル画像のモーフィングそのものを諦め、transition:animate="none"を指定してフェードだけで済ませるのが実務的な判断です。逆に、ヘッダーのロゴやアイキャッチ画像のように1ページに1つしか存在せずtransition:nameが固定文字列で済む要素であれば、その名前を直接指定するだけでobject-fitを確実に適用できます。
/* ヘッダーロゴなど、transition:name が固定文字列の要素にだけ確実に効く */
::view-transition-old(site-logo),
::view-transition-new(site-logo) {
object-fit: cover;
}
サムネイル画像のように個体ごとに名前が変わる要素にモーフィングを諦めずに適用したい場合は、ビルド時にページが持つtransition:nameの一覧を生成し、CSSを動的に出力する仕組みを別途用意するか、後述するtransition:persistで画像要素そのものをDOMごと引き継ぐ方式に切り替えることも検討してください。
DOM構造の観点では、もう1点注意が必要です。transition:nameを指定した要素を、ページごとに異なる<div>や<figure>でラップしてしまうと、ラップ要素ごとボックスサイズが変化しモーフィングの基準になるバウンディングボックスが歪みます。一覧ページでは<img>を直接<a>の子要素にしているのに、詳細ページでは<figure><img /><figcaption>...</figcaption></figure>のように余分なpaddingやマージンを持つ要素で囲んでしまうケースは典型的な失敗パターンです。named要素のラッパー構造(margin・padding・display)は、遷移元・遷移先でできるだけ揃えておくことが、レイアウト崩れを防ぐ最も基本的な原則です。
Tailwind CSSや独自CSSを組み合わせたオリジナルアニメーションの作り方
組み込みのfade・slideを使うだけでなく、transition:animateには独自定義のCSSアニメーションをオブジェクトとして渡すことができます。Astroが提供する型は次の3つです。
export interface TransitionAnimation {
name: string; // @keyframes の名前
delay?: number | string;
duration?: number | string;
easing?: string;
fillMode?: string;
direction?: string;
}
export interface TransitionAnimationPair {
old: TransitionAnimation | TransitionAnimation[];
new: TransitionAnimation | TransitionAnimation[];
}
export interface TransitionDirectionalAnimations {
forwards: TransitionAnimationPair;
backwards: TransitionAnimationPair;
}
oldは遷移元ページの要素、newは遷移先ページの要素に対するアニメーション、forwards/backwardsは進む方向・戻る方向で別々の挙動を定義できることを意味します。@keyframesそのものはグローバルCSSに通常通り定義し、Tailwind CSSのユーティリティクラスは見た目(色・余白・角丸など)のスタイリングに専念させ、アニメーションのロジックはCSS側、装飾はTailwind側という役割分担にすると保守性が高まります。
---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="ja">
<head>
<ClientRouter />
</head>
<body class="bg-white text-slate-900">
<slot />
</body>
</html>
<style is:global>
@keyframes slide-up-in {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-up-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-24px); }
}
</style>
---
// src/pages/blog/[slug].astro
const slideUp = {
old: { name: 'slide-up-out', duration: '0.3s', easing: 'ease-in' },
new: { name: 'slide-up-in', duration: '0.4s', easing: 'ease-out' },
};
const customTransition = {
forwards: slideUp,
backwards: slideUp,
};
---
<main
class="mx-auto max-w-2xl px-6 py-12 rounded-2xl shadow-sm"
transition:animate={customTransition}
>
<slot />
</main>
組み込みアニメーションをベースに微調整したい場合は、astro:transitionsからエクスポートされているfade関数やslide関数をインポートし、durationオプションだけを上書きすることもできます。
---
import { fade } from 'astro:transitions';
---
<header transition:animate={fade({ duration: '0.4s' })}>
...
</header>
カスタムアニメーションを設計する際の実務的な注意点として、forwardsとbackwardsに同じTransitionAnimationPairをそのまま使い回すと、戻る操作でも進む操作と同じ方向にスライドしてしまい不自然に見えるケースがあります。前へ進むときは右から左、戻るときは左から右、といった方向性を演出したい場合は、backwards側でoldとnewのキーフレームを入れ替えるか、direction: “reverse“を指定したアニメーションを別途用意してください。
ページ遷移後にJavaScriptが動かない問題の解決法
<ClientRouter />を導入した直後に最も多く報告される不具合が、「2ページ目以降でJavaScriptが動かなくなる」という現象です。これはバグではなく、ブラウザのフルリロードが発生しないことによる仕様上の挙動です。原因を正しく理解し、astro:page-load・astro:after-swapという2つのライフサイクルイベントを使って初期化処理を書き直すことで解決できます。
ページ遷移後にJavaScriptが再実行されない理由
<ClientRouter />によるページ遷移では、ブラウザのフルページリロードが一度も発生しません。fetchで取得した新しいページのHTMLをDOMスワップしているだけなので、通常のブラウザナビゲーションで毎回発生していたDOMContentLoadedやloadイベントは、初回アクセス時の1回しか発火しません。document.addEventListener(“DOMContentLoaded“, ...)で書かれたハンバーガーメニューの開閉処理やアコーディオンの初期化スクリプトは、2ページ目以降では呼び出されるタイミング自体が存在しなくなるため、クリックしても何も反応しなくなります。
さらにAstroのスクリプト実行ルールには、見落としやすい仕様があります。
| スクリプトの種類 | ページ遷移時の挙動 |
|---|---|
通常の<script>(Astro/Viteによってバンドルされるモジュールスクリプト) | 初回実行後は二度と実行されない。同じスクリプトが新しいページに存在していても無視される |
is:inlineを付けたインラインスクリプト | ページの内容が変わるたびに再実行される可能性がある(保証はされない) |
つまり、デフォルトの<script>タグの中に書いたDOMContentLoadedベースの初期化コードは、ページ遷移後は確実に動かなくなると考えてください。これは「たまに起きる不具合」ではなく、View Transitions導入時に必ず直面する仕様です。
astro:page-loadとastro:after-swapの使い方
この問題への対応として、AstroはDOMContentLoadedの代替となるastro:page-loadイベントと、DOMスワップ直後に発火するastro:after-swapイベントを提供しています。両者は発火するタイミングが異なるため、用途に応じて使い分けることが重要です。
| イベント名 | 発火タイミング | 主な用途 |
|---|---|---|
astro:after-swap | DOMスワップ完了直後。画面がまだ描画される前 | ダークモードのクラス付与など、描画前に終わらせたい処理 |
astro:page-load | 新しいスクリプトの実行が終わり、ナビゲーション全体が完了した後 | DOMContentLoadedの置き換え。UIの初期化処理全般 |
ハンバーガーメニューの初期化であれば、DOMContentLoadedをastro:page-loadに置き換えるだけで解決します。
// src/scripts/menu.js
document.addEventListener('astro:page-load', () => {
const button = document.querySelector('.hamburger');
const nav = document.querySelector('.nav-links');
button?.addEventListener('click', () => {
nav?.classList.toggle('expanded');
});
});
ダークモードのように「画面が一瞬でも誤った見た目で表示されるとチラつきが目立つ」処理は、描画前に発火するastro:after-swapで処理します。
<script is:inline>
function applyTheme() {
const isDark = localStorage.getItem('theme') === 'dark';
document.documentElement.classList.toggle('dark', isDark);
}
document.addEventListener('astro:after-swap', applyTheme);
applyTheme(); // 初回読み込み時にも実行する
</script>
落とし穴として、define:varsでフロントマターの変数をスクリプトに渡している場合、そのスクリプトはページのパスが変わるたびに内容が異なるインラインスクリプトとして毎回再実行されます。このスクリプト内でaddEventListenerを呼んでいると、ページを遷移するたびにリスナーが重複して登録され、クリック1回でハンドラが複数回発火するという不具合につながります。再実行されうるスクリプトの中でイベントリスナーを登録する場合は、必ず{ once: true }オプションを付けるか、登録前にグローバル変数で「すでに登録済みかどうか」を確認する処理を入れてください。
document.addEventListener(
'astro:page-load',
() => {
console.log('このリスナーは1回しか実行されない');
},
{ once: true }
);
GA4・外部ライブラリ・イベントリスナーを再初期化する方法
Google Analytics(GA4)の標準スニペット(gtag.js)は、スクリプト読み込み時に1回だけpage_viewイベントを送信する設計になっています。<ClientRouter />導入後は2ページ目以降でこのスクリプトが再実行されないため、セッション中の最初の1ページしかGA4に計測されないという事故が非常によく発生します。
解決策は、GA4の自動page_view送信を無効化し、astro:after-swapまたはastro:page-loadのタイミングでpage_viewイベントを手動送信することです。
---
// src/components/CommonHead.astro
---
<script is:inline async src="<https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX>"></script>
<script is:inline>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
// 自動のpage_view送信をオフにする
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
// 初回読み込みとページ遷移の両方でpage_viewを手動送信する
function sendPageView() {
gtag('event', 'page_view', {
page_title: document.title,
page_location: window.location.href,
page_path: window.location.pathname,
});
}
document.addEventListener('astro:page-load', sendPageView);
</script>
このとき、GA4のタグ自体はis:inlineを付けたインラインスクリプトとして配置してください。Astroが通常のスクリプトをモジュールとしてバンドル・最適化する処理に巻き込まれると、gtag関数のグローバル公開や実行順序が崩れ、タグが読み込まれているのに発火しないという診断が難しい不具合を招きます。
GSAPやSwiper、Alpine.jsといったDOM操作を前提とする外部ライブラリも、同様の理由で再初期化が必要です。これらのライブラリはインスタンス生成時に対象のDOM要素への参照を保持しますが、<ClientRouter />がDOMをスワップすると、ライブラリが参照していた古いDOM要素は破棄され、新しいDOM要素には何も紐付いていない状態になります。
// src/scripts/swiper-init.js
import Swiper from 'swiper';
import 'swiper/css';
let swiperInstance;
function initSwiper() {
// 前のページのインスタンスが残っていれば破棄してから再生成する
if (swiperInstance) {
swiperInstance.destroy(true, true);
}
const el = document.querySelector('.swiper');
if (el) {
swiperInstance = new Swiper(el, {
loop: true,
pagination: { el: '.swiper-pagination' },
});
}
}
document.addEventListener('astro:page-load', initSwiper);
ここでdestroy()を呼ばずに毎回new Swiper(...)してしまう実装は典型的なアンチパターンです。古いインスタンスのイベントリスナーやDOM参照がメモリ上に残り続け、ページ遷移を繰り返すたびにメモリ使用量が増加するメモリリークの原因になります。サードパーティライブラリを<ClientRouter />環境に組み込む際は、「初期化」だけでなく「破棄」のタイミングも必ずセットで設計することを徹底してください。
状態を維持するtransition:persistの実践テクニック
transition:persistは、ページ遷移時に要素を置き換えるのではなく、DOMごと次のページへそのまま引き継ぐためのディレクティブです(astro@2.10.0で追加)。動画の再生状態やフォームの入力内容、フレームワークコンポーネントの内部State(状態)まで、JavaScriptの変数として保持しているものではなく、DOM要素そのものを生き残らせるという発想で実装すると挙動を正確にコントロールできます。
ダークモード・テーマ設定を保持する方法
ダークモードの設定値そのものは、通常localStorageに保存されているため、ページ遷移を挟んでも値自体は消えません。本当の課題は「新しいページが描画される前に、保存済みの設定を反映できているか」というタイミングの問題であり、これは前章で解説したastro:after-swapイベントでクラスを切り替える実装(描画前にライト/ダークのクラスを付与してチラつきを防ぐ処理)で対応済みです。
transition:persistの出番は、テーマ切り替えのUI自体がReactやVueなどのフレームワークコンポーネント(Astro Island)として実装されている場合です。例えば、トグルスイッチのつまみが滑らかにアニメーションするテーマ切替ボタンをVueコンポーネントで実装しているケースを考えます。transition:persistを付けずにページ遷移すると、コンポーネントは一度破棄されて新しいページで再マウントされるため、スイッチの切り替えアニメーションが毎回ページ遷移のたびに再生され直してしまいます。
---
// src/components/Header.astro
import ThemeToggle from '../components/ThemeToggle.vue';
---
<header>
<ThemeToggle client:load transition:persist />
</header>
transition:persistを付与すると、コンポーネントのインスタンスそのものが新しいページに引き継がれるため、トグルスイッチの内部アニメーション状態やイベントリスナーが破棄されず、見た目にも一切ちらつきません。ヘッダーのように全ページで共通して表示される島(Island)コンポーネントには、基本的にtransition:persistを付けておくことを推奨します。
フォーム入力内容・スクロール位置を保持する方法
検索ボックスのようにヘッダーなど共通レイアウトに常設されているフォーム要素は、transition:persistを付けるだけで入力中の文字列を保持できます。理由は単純で、要素が新しいDOMに置き換えられず、ユーザーが入力したテキストを保持しているDOMノードそのものが次のページでも生き続けるためです。
---
// src/components/Header.astro
---
<form action="/search" transition:persist>
<input type="search" name="q" placeholder="記事を検索" />
</form>
サイドバーのナビゲーションリストのように、要素内部に独自のスクロール位置を持つコンポーネントも同様の理由で恩恵を受けます。ページ全体のスクロール復元はAstroのルーターが自動的に処理しますが、サイドバー内部のスクロール位置のように「要素単位の状態」は、ルーターの自動復元の対象外です。transition:persistでサイドバー要素自体を引き継げば、内部のスクロール位置はDOMの状態として自然に保持されます。
---
// src/components/Sidebar.astro
---
<nav class="sidebar" transition:persist>
<ul>
<!-- 大量のリンクを含むナビゲーション -->
</ul>
</nav>
注意点として、transition:persistはあくまで「DOM要素を引き継ぐ」仕組みであり、フォームの入力値をJavaScriptの変数やストア(Reduxやpiniaなど)で管理している場合は対象外です。フォームをフレームワークコンポーネントとして実装しており、入力値をコンポーネントの内部State(useStateなど)で管理している場合は、フォーム要素そのものだけでなくコンポーネント全体(Island)にtransition:persistを付与してください。
音楽プレイヤーや動画再生を途切れさせない実装パターン
transition:persistが最も分かりやすく効果を発揮するのが、音楽プレイヤーや動画プレイヤーのように再生状態を持つメディア要素です。通常のページ遷移であれば、新しいページに切り替わった瞬間に<video>要素は破棄され、再生も止まります。transition:persistを付与した<video>要素は、ページが切り替わっても同じDOMノードとして存在し続けるため、再生中の動画は途切れることなく流れ続けます。
---
// src/components/Layout.astro
---
<video controls muted autoplay transition:persist>
<source src="/media/bgm.mp4" type="video/mp4" />
</video>
この挙動は前進・後退どちらのナビゲーションでも有効です。ただし、遷移元と遷移先で要素の種類や使用しているコンポーネントが異なる場合、Astroは自動的にペアを推測できません。例えば一覧ページでは素の<video>タグ、詳細ページでは<MyVideoPlayer />という別コンポーネントを使っている場合は、両方に同じtransition:nameを明示してペアリングしてください。
<!-- 遷移元: src/pages/index.astro -->
<video controls muted autoplay transition:name="media-player" transition:persist />
<!-- 遷移先: src/pages/now-playing.astro -->
<MyVideoPlayer transition:name="media-player" transition:persist />
なお、transition:persistはtransition:persist=“media-player“という形で値に名前を渡すショートハンド記法もサポートされています。これはtransition:persistとtransition:name=“media-player“を同時に指定するのと同じ意味になり、コードを短く書けます。
<video controls muted autoplay transition:persist="media-player" />
ReactやVueのコンポーネントをclient:ディレクティブで配置したAstro Islandにもtransition:persistは適用できます。プレイリストの再生位置や再生中トラックのインデックスをコンポーネント内部のStateで管理している音楽プレイヤーであれば、ページを移動しても再生中のトラックと再生位置がリセットされません。
---
import AudioPlayer from '../components/AudioPlayer.jsx';
---
<AudioPlayer client:load transition:persist initialTrack={5} />
ここでさらに注意すべき点がtransition:persist-propsです(astro@4.5.0で追加)。デフォルトでは、transition:persistを付けたIslandはStateを保持しつつも、Propsだけは新しいページの値で再レンダリングされます。例えばページごとに異なるtitleプロパティを渡している場合、これは多くのケースで望ましい挙動です。しかし、Propsも含めて完全に元の状態のまま固定したい場合は、transition:persist-propsを追加で指定してください。
<AudioPlayer client:load transition:persist transition:persist-props initialTrack={5} />
最後に、transition:persistを使っても回避できない既知の制限を把握しておく必要があります。要素を引き継いだとしても、CSSアニメーションは再生し直されてしまい、内部に<iframe>を含む場合はiframeそのものが再読み込みされます。YouTube埋め込みプレイヤーなどiframeベースの動画プレイヤーを途切れさせずに再生し続けたい場合、transition:persistでは解決できないため、postMessage APIで再生位置を制御するか、iframeを使わない自前の動画プレイヤー実装に切り替える必要があります。
フォーム送信・クエリパラメータ・動的ルーティングとの組み合わせ
<ClientRouter />は<form>タグによるGET/POST送信にも対応しており、検索フォームやフィルター機能のページ遷移にもアニメーションが適用されます。一方で、クエリパラメータを使ったフィルタリングやページネーションでは、Astroの自動要素マッチングが原因で意図しないモーフィングが発生することがあります。動的ルーティングと組み合わせる際の注意点も含め、実務で詰まりやすいポイントを整理します。
GET/POSTフォーム送信時の注意点と正しい遷移処理
<ClientRouter />はastro@4.0.0以降、<form>要素からのGET送信・POST送信の両方をインターセプトしてクライアントサイド遷移を発生させます。検索フォームを送信したときも、通常のリンククリックと同じようにフェードアニメーションが適用されます。
<form action="/search" method="GET">
<input type="search" name="q" />
<button type="submit">検索</button>
</form>
ここで最も実務でつまずきやすいポイントは、POST送信時のエンコーディング形式です。Astroの<ClientRouter />は、method="POST"のフォームをデフォルトでmultipart/form-dataとしてエンコードして送信します。これは通常のブラウザのデフォルト挙動(application/x-www-form-urlencoded)とは異なるため、バックエンドのAPIルートがapplication/x-www-form-urlencodedを前提に実装されていると、フォームの値が正しくパースされずサーバー側でエラーになる事故が頻発します。ブラウザの標準動作と揃えたい場合は、enctype属性を明示してください。
<form
action="/contact"
method="POST"
enctype="application/x-www-form-urlencoded"
>
<input type="text" name="name" />
<button type="submit">送信</button>
</form>
決済フォームやファイルアップロードのように、クライアントサイド遷移をそもそも望まないフォームには、リンクと同様にdata-astro-reload属性を付与してブラウザのデフォルト送信に戻すことができます。
<form action="/checkout" method="POST" data-astro-reload>
...
</form>
クエリパラメータ付きURLでアニメーションが崩れる原因と対策
クエリパラメータの変化だけでページ内容が変わるフィルター機能やページネーションでは、「同じページコンポーネントなのにアニメーションが不自然」という相談が非常に多く発生します。原因は、Astroの自動要素マッチングが要素の種類とDOM上の位置だけを基準にペアを推測していることにあります。
例えば商品一覧を「価格が安い順」から「人気順」に並び替えた場合、URLは/products?sort=priceから/products?sort=popularに変わるだけで、ページのDOM構造(カードの並ぶ位置)はほぼ同じです。この場合、Astroは「3番目の位置にあったカードA」と「並び替え後に3番目の位置に来たカードB」を同じ要素のペアだと誤認識し、本来まったく違う商品同士をモーフィングさせてしまいます。これが「フィルタリング後にアニメーションが不自然」と感じる主な原因です。
対策は、位置ベースの自動マッチングに依存せず、コンテンツの内容に紐づいたtransition:nameを明示的に指定することです。商品IDのような一意な識別子を名前に含めておけば、並び替えや絞り込みでDOM上の位置が変わっても、Astroは正しく同一商品同士をペアリングします。
{products.map((product) => (
<div transition:name={`product-${product.id}`}>
<img src={product.thumbnail} alt={product.name} />
<p>{product.name}</p>
</div>
))}
絞り込みによってそもそも一覧から消える商品については、ペアとなる新しい要素が存在しないため、Astroは自動的に通常のフェードイン/フェードアウトにフォールバックします。これは正しい挙動であり、すべての要素を無理にモーフィングさせる必要はありません。
もう1つ知っておくべき実務上の注意点は、同じパス・同じクエリパラメータへ向かうリンクをクリックした場合でも、Astroの<ClientRouter />はトランジションを再生してしまうことです(withastro/astro#14034)。タブ切り替えのようなUIで「現在選択中のタブ」を再クリックした際に毎回アニメーションが走ってしまう場合は、クリックハンドラ側で現在のURLと遷移先URLを比較し、一致する場合はナビゲーションを発生させないようにアプリケーション側で制御してください。
コンテンツコレクションや動的ルートとの組み合わせ方
getCollection()やgetStaticPaths()を使った動的ルート(src/pages/blog/[slug].astroなど)は、ビルド時に静的なページとして個別に生成されるため、View Transitionsとの組み合わせにおいて特別な設定は不要です。各ページは独立した通常のAstroページとして扱われ、<ClientRouter />を共通レイアウトに配置していれば、動的に生成されたページ同士でも変わらず遷移アニメーションが適用されます。
実装上で意識すべきなのは、前章までで解説してきたtransition:nameの一意性をslugやIDで担保するという原則を、動的ルートでも一貫して適用することです。
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
---
<article transition:name={`article-${post.slug}`}>
<h1>{post.data.title}</h1>
<Content />
</article>
output: 'server'(SSR)モードでNode.jsアダプターなどを使い、クエリパラメータやデータベースの値に応じて動的にレンダリングするページと組み合わせる場合は、追加で注意が必要です。一部のバージョンで、<ClientRouter />経由のナビゲーションがサーバーに対して同じURLに2回リクエストを送ってしまう不具合が報告されています(withastro/astro#15441)。SSRページでデータベースへの書き込みを伴うAPIや、課金が発生する外部APIをページレンダリング時に呼び出している場合、二重リクエストは無視できない実害につながります。SSRアダプターと<ClientRouter />を併用するプロジェクトでは、本番投入前に必ずネットワークタブで同一URLへの重複リクエストが発生していないかを確認し、該当する場合はAstroの変更履歴で修正状況を確認してからバージョンを選定してください。
Server Islands(server:defer)と<ClientRouter />を併用する場合も同様に、クライアントサイド遷移後にServer Island内のコンポーネントが正しくハイドレートされない既知の問題が報告されています(withastro/astro#12780)。Server Islandsを多用するページでView Transitionsを導入する際は、該当ページだけdata-astro-reloadで通常遷移にフォールバックさせるという妥協も、実務上は有効な選択肢になります。
ブラウザ対応状況とフォールバック設定
View Transitions APIのブラウザ対応は2026年に入り大きく前進しました。とはいえ、Astroの<ClientRouter />を使う限り、ブラウザ間の対応差はほとんど意識する必要がありません。それでも、最新の対応状況とフォールバックの仕組みを正しく理解しておくことは、本番運用前のブラウザテストや、意図的にアニメーションをオフにしたい場面で欠かせません。
View Transitions APIの最新対応状況
View Transitions APIには「同一ドキュメント内の遷移(Same-document)」と「別ドキュメントへの遷移(Cross-document)」の2種類があり、対応状況もそれぞれ異なります。Astroの<ClientRouter />は内部的にSame-documentのAPI(document.startViewTransition())を使ってMPAをシミュレートしているため、Astroユーザーが本当に注目すべきはSame-document APIの対応状況です。
| ブラウザ | Same-document対応 | Cross-document対応 |
|---|---|---|
| Chrome / Edge | 111以降 | 126以降 |
| Safari | 18以降 | 18.2以降 |
| Firefox | 144以降 | 144以降 |
Same-documentのView Transitions APIは、2025年10月に主要ブラウザでBaseline(標準として広く利用可能な状態)に到達しています。これにより、Firefox 144以降では<ClientRouter />を使った遷移がAstro独自のフォールバックシミュレーションではなく、ブラウザネイティブのView Transitions APIとして本物の動作をするようになりました。transition:nameやtransition:animateもFirefoxの最新版で他のブラウザと同じように機能します。
一方で、Firefox 144より前のバージョンや、Safari 18より前のバージョンでは、ネイティブAPIが存在しないためAstroが提供する独自のフォールバックシミュレーションに切り替わります。transition:persistやtransition:persist-propsはView Transitions APIそのものとは独立した機能のため、フォールバック環境でもfallback="none"を指定していない限り問題なく動作します。
非対応ブラウザ向けProgressive Enhancementの実装方法
Astroの<ClientRouter />は、追加設定なしでもProgressive Enhancement(段階的な機能強化)の原則に従って動作します。View Transitions APIに対応していないブラウザでクライアントサイド遷移自体が壊れることはなく、fallbackプロパティで挙動を3段階から選択できます。
fallbackの値 | 動作 | 推奨される場面 |
|---|---|---|
animate(デフォルト) | カスタム属性を使ってAstroが遷移アニメーションを再現する | 基本的にこのままで問題ない |
swap | アニメーションなしで即座にDOMを入れ替える | アニメーションよりも表示速度を優先したい場合 |
none | 非対応ブラウザでは通常のフルページナビゲーションにフォールバックする | View Transitionsに依存したJSを一切書きたくない場合 |
---
import { ClientRouter } from 'astro:transitions';
---
<ClientRouter fallback="swap" />
animateのフォールバックシミュレーションは、内部的にdata-astro-transition-fallbackという属性をHTML要素に付与することで実現されています。この属性はastro:before-swapイベントの直前に"old"、astro:after-swapイベントの直後に"new"という値に切り替わり、対応するCSSアニメーションをトリガーします。ネイティブ対応ブラウザではこの属性は付与されないため、CSS側でview-transition-nameプロパティを直接指定するよりも、Astroのtransition:nameディレクティブを使う方が、ネイティブ環境とフォールバック環境の両方で一貫した挙動を保てます。
ほとんどのプロジェクトでは、デフォルトのanimateのままにしておくことを推奨します。noneを選択すると、Safari 18未満やFirefox 144未満のユーザーにはそもそもクライアントサイド遷移自体が発生せず通常のフルページ遷移になるため、transition:persistで実現していた動画の継続再生やフォームの入力保持といった機能も失われる点に注意してください。
非対応環境でアニメーションを自然に無効化する方法
ユーザーがOSやブラウザの設定で「視覚効果を減らす(reduce motion)」を有効にしている場合、Astroの<ClientRouter />は追加の実装なしで自動的にすべてのアニメーションを無効化します。これはネイティブのView Transitions APIによる遷移だけでなく、フォールバックシミュレーションによるアニメーションも含めて無効化される仕組みです。prefers-reduced-motionに対応するためにJavaScriptでwindow.matchMediaを監視するような実装は、<ClientRouter />を使っている限り基本的に不要です。
/* <ClientRouter /> が内部的に適用しているCSSのイメージ
開発者が個別に書く必要はない */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
ただし、transition:animateで独自に定義したカスタムアニメーション(@keyframesを使ったオリジナルの動き)についても、この自動無効化の対象に含まれます。動きの大きいカスタムアニメーションを実装する際は、わざわざprefers-reduced-motionの分岐コードを書く必要がないという前提で設計を進めて問題ありません。
非対応ブラウザでの見え方を実際に確認したい場合、Chrome DevToolsの「レンダリング」タブからprefers-reduced-motionをエミュレートできます。本番公開前には、View Transitions対応ブラウザ・フォールバック環境・reduce motion設定の3パターンを最低限確認しておくことで、想定外の見た目崩れを防げます。
パフォーマンス・SEO・アクセシビリティへの影響
「アニメーションを追加するとサイトが重くなるのでは」「SEOに悪影響が出るのでは」という懸念は、View Transitions導入を検討する際に必ず出てきます。結論から言うと、正しく実装していればCore Web VitalsやSEOへの悪影響はほとんどありませんが、CLSの算出ルールやLighthouseの計測範囲を正確に理解していないと、思わぬ落とし穴にはまります。
Core Web Vitals(CLS・INP)への影響と検証方法
CLS(Cumulative Layout Shift)には、ユーザーの操作(クリックなど)から500ミリ秒以内に発生したレイアウト変化はスコアに加算しないという「グレースピリオド」のルールがあります。これはSPA的な画面遷移を念頭に置いた仕様で、Googleもクリック後のトランジションがこの猶予期間を超えて続く場合、超過後に起きたレイアウト変化はCLSとしてカウントされると明言しています。
つまり、View Transitionsのアニメーション時間そのものが直接CLSを悪化させるわけではありませんが、フェードやスライドのアニメーション時間に加えて、新しいページの画像読み込みやフォント読み込みが重なって合計500ミリ秒を超えてレイアウトが安定しない場合、その分はCLSの悪化要因として計測されます。アニメーションのdurationを1秒近くまで延ばしているプロジェクトでは、この点を意識してください。
実務で気をつけるべきCLSの原因は、View Transitions固有のものではなく、通常のWebパフォーマンス対策と同じです。
- 画像・動画に
width・height属性(またはCSSでの明示的なアスペクト比指定)を設定し、読み込み中の領域を確保する - Webフォントの遅延読み込みによる文字サイズ変化(FOUT)を
font-displayの設定で抑制する - 遷移後に広告や埋め込みコンテンツが遅れて挿入され、コンテンツを押し下げないようにする
なお、Astroの<ClientRouter />は、遷移先に新しいスタイルシートが含まれる場合、そのスタイルシートの読み込みが完了するまでトランジションの完了を待ってから画面を確定させます。これによりスタイル未適用のまま一瞬表示される「FOUC」が起きにくく、CSSの遅延読み込みが原因のレイアウト崩れは、素のSPAルーターを自作する場合よりも発生しにくい設計になっています。
INP(Interaction to Next Paint)への影響については、document.startViewTransition()のコールバック内で重い同期処理を行わないことが唯一かつ最も重要な原則です。DOMスワップ自体は一瞬で終わりますが、ここに重いJavaScript処理(巨大な配列のソートや、同期的なDOM全体の走査など)を割り込ませると、メインスレッドが長時間ブロックされ、次のユーザー操作への応答が遅延してINPスコアが悪化します。astro:page-loadやastro:after-swapで実行する初期化処理が肥大化している場合は、requestIdleCallbackやsetTimeoutで処理を遅延させ、メインスレッドを早く解放することを検討してください。
検証方法としては、Chrome DevToolsの「パフォーマンス」パネルでページ遷移を録画し、トランジション中のメインスレッドの動きを確認するのが最も確実です。本番環境のリアルユーザーデータを継続的に見たい場合は、web-vitalsライブラリを使い、CLSとINPの実測値を自社の分析基盤やGA4へ送信する仕組みを構築してください。
Lighthouseスコアへの影響と改善ポイント
Lighthouseのスコアに与える影響を正しく評価するには、Lighthouseが何を計測しているかを理解しておく必要があります。標準的なLighthouseのナビゲーションモードは、ブラウザの初回ロード(フルページロード)を計測対象としています。<ClientRouter />によるクライアントサイド遷移は2ページ目以降の挙動であり、Lighthouseの通常のレポートにはクライアントサイド遷移そのものの体感速度は直接反映されません。
したがって、<ClientRouter />導入がLighthouseスコアに与える影響は、主に次の1点に集約されます。
<ClientRouter />自体のJavaScriptバンドルサイズが、初回ロード時のJS実行時間にわずかに加算される
このスクリプトは数KB程度と軽量で、Reactルーターのような大規模なクライアントサイドルーティングライブラリと比較すると影響は限定的です。それでもスコアを少しでも改善したい場合は、以下のポイントを優先的に確認してください。
transition:persistで引き継いでいる要素(動画や音楽プレイヤーなど)が、本来不要なページにも常駐し続けて不要なリソースを消費していないか- 一覧ページのカードすべてに
transition:nameを付与し、不要に多くの named 要素を生成してブラウザの内部処理負荷を増やしていないか - カスタムアニメーションの
durationが長すぎて、ユーザーが次の操作に移るまでの待機時間を不必要に伸ばしていないか
Lighthouseのスコアそのものを最終目標にするのではなく、実際のユーザー体感速度(フィールドデータ)を優先して判断する姿勢が重要です。PageSpeed InsightsのChrome UXレポート(CrUX)データは実際のユーザー環境を反映するため、Lighthouseのラボデータと併用して確認することを推奨します。
prefers-reduced-motion対応と視覚過敏ユーザーへの配慮
視覚的なアニメーションは、前庭機能障害(めまいや乗り物酔いに似た症状を引き起こす内耳の障害)を持つユーザーや、視覚過敏の特性を持つユーザーにとって、単なる好みの問題ではなく実際の身体的な不快感や症状の原因になり得ます。前章で解説したとおり、<ClientRouter />はprefers-reduced-motionの設定を検知すると、組み込みアニメーションだけでなく開発者が独自に定義したカスタムアニメーションも含めて自動的に無効化するため、この観点での基本的な配慮はAstro側で標準実装されています。
アクセシビリティの観点でもう1つ重要な仕組みが、<ClientRouter />に内蔵されているルートアナウンサーです(astro@3.2.0で追加)。通常のフルページ遷移では、ページが切り替わるたびにスクリーンリーダーが新しいページの存在を自動的にユーザーへ伝えますが、クライアントサイド遷移ではブラウザの標準的なページ読み込みが発生しないため、この自動アナウンスが行われません。<ClientRouter />は遷移後の新しいページにaria-live="assertive"を設定した要素を挿入し、<title>タグの内容(見つからない場合は最初の<h1>、それもなければURLのパス名)を即座にスクリーンリーダーへ読み上げさせることで、この欠落を補っています。
この機能を正しく機能させるための実務上の条件は1つだけです。すべてのページに意味のある<title>タグを必ず設定してください。 動的ルートで<title>の設定を忘れると、ルートアナウンサーはURLのパス名をそのまま読み上げてしまい、スクリーンリーダーを利用するユーザーにとって「今どのページに来たのか」が分かりにくくなります。View Transitionsの導入はビジュアル面の改善だけでなく、こうした基本的なアクセシビリティ要件を見直す良い機会として捉えることをおすすめします。
よくある質問(FAQ)
-
Astro 6で
<ViewTransitions />をそのまま使うとどうなりますか? -
ビルドエラーになります。Astro 6.0で
<ViewTransitions />は完全に削除されており、astro:transitionsからViewTransitionsという名前のエクスポート自体が存在しません。import { ClientRouter } from 'astro:transitions';に書き換え、コンポーネント名も<ClientRouter />に置き換えてください。
-
View Transitionsを導入するとSEOに悪影響はありますか?
-
基本的にはありません。
<ClientRouter />はクロールやインデックスの仕組みを変更するものではなく、見た目の遷移にアニメーションを追加するだけです。注意すべき点は、ルートアナウンサー機能が正しく動作するためにすべてのページに意味のある<title>タグを設定することと、fallback="none"を選択した場合に非対応ブラウザでは通常のフルページ遷移になるため、その挙動を踏まえてサイト設計を行うことです。
-
Firefoxでも遷移アニメーションは動きますか?
-
Firefox 144以降では、ブラウザネイティブのView Transitions APIとして本物のアニメーションが動作します。Firefox 144未満の場合は、Astroが提供するフォールバックシミュレーションに自動的に切り替わるため、いずれの場合も画面遷移自体が壊れることはありません。
-
transition:nameは1ページに何個まで設定できますか? -
数に上限はありませんが、同一ページ内で同じ値を重複させることはできません。記事一覧のカードのように同じ構造の要素が繰り返し並ぶ場合は、
transition:name={thumbnail-${post.slug}}のように一意な識別子を埋め込んで動的に生成してください。
-
transition:persistを使えば、どんな状態でも遷移後に引き継げますか? -
いいえ、引き継げない状態もあります。
transition:persistはDOM要素そのものを次のページに引き継ぐ仕組みですが、CSSアニメーションは遷移後に再生し直され、<iframe>は引き継いでも再読み込みされます。YouTube埋め込みのようなiframeベースの動画を途切れさせずに再生したい場合は、transition:persistでは解決できません。
-
GA4のページビューがセッション中1回しか計測されないのはなぜですか?
-
GA4標準の
gtag.jsスニペットは、スクリプト読み込み時に1回だけpage_viewイベントを送信する設計になっているためです。<ClientRouter />によるクライアントサイド遷移ではスクリプトが再実行されないため、2ページ目以降が計測されません。gtag('config', ..., { send_page_view: false })で自動送信を無効化し、astro:page-loadイベント内でgtag('event', 'page_view', ...)を手動送信するように実装を変更してください。
-
本番運用中の既存サイトに後から導入しても問題ありませんか?
-
問題ありません。共通レイアウトに
<ClientRouter />を1行追加するだけで導入できる、追加的な機能です。ただし、DOMContentLoadedを前提に書かれた既存のスクリプトは2ページ目以降で動作しなくなるため、astro:page-loadへの置き換えが必要になります。本番投入前に主要なインタラクティブ要素(メニュー、フォーム、外部ライブラリ)が遷移後も正しく動くか、一通り確認してください。
-
View Transitionsを導入するとサイトが重くなりますか?
-
<ClientRouter />自体のJavaScriptは数KB程度と軽量で、Lighthouseスコアへの影響は限定的です。重くなるとすれば、transition:persistで不要な要素を多数引き継いでいたり、カスタムアニメーションのdurationを必要以上に長く設定していたりする場合です。アニメーションの完了処理に重い同期JavaScriptを挟まないようにすれば、INP(応答性)への悪影響も避けられます。
-
Next.jsやNuxtに移行しなくても、AstroだけでSPAのような体験は作れますか?
-
作れます。これはAstroの
<ClientRouter />が提供する価値そのものです。Islands アーキテクチャによる「デフォルトでJSゼロ」の構成を維持したまま、ページ遷移だけをSPA風に滑らかにできるため、フレームワーク全体の移行という大きな意思決定をせずにUXを改善できます。
-
ブラウザの戻る・進むボタンでも正しく動きますか?
-
動きます。
<ClientRouter />はクリックによる遷移だけでなく、ブラウザの戻る・進む操作もインターセプトしてアニメーションを適用し、スクロール位置も自動的に復元します。サイト全体にscroll-behavior: smoothを指定している場合は、履歴移動のたびにスクロールアニメーションが走って不自然に感じられることがあるため、astro:before-swapイベントのnavigationTypeプロパティで履歴移動時だけ挙動を調整する実装を検討してください。
-
結局、自分のプロジェクトにView Transitionsを導入すべきですか?
-
見た目の一貫性を重視するコーポレートサイトやポートフォリオ、動画・音楽プレイヤーのように状態を維持したいコンテンツがあるサイトであれば、導入する価値は高いと言えます。一方で、JavaScriptを極限まで減らした静的サイトを志向している場合や、ブラウザネイティブのCross-document View Transitionsだけで十分なシンプルなフェード演出で満足できる場合は、
<ClientRouter />を使わずCSSの@view-transitionルールだけで対応するという選択肢も十分に合理的です。transition:persistによるステート維持や、Firefoxを含めた全ブラウザへの一貫したフォールバックが必要かどうかが、最終的な判断基準になります。
まとめ
Astro 6で進化したView Transitions(ClientRouter)の実装方法と、実務でのトラブルを解決するテクニックを網羅的に解説してきました。
Astro最大の強みである「圧倒的な軽量さ(ゼロJS / MPA構造)」を極限まで保ったまま、Next.jsやNuxtのような滑らかで心地よいSPA風のユーザー体験(UX)を両立できるこの機能は、これからのモダンなWeb制作において強力な武器になります。
ここで、本記事で解説した特に重要なポイントを振り返ってみましょう。
ブラウザ標準のWeb APIに準拠したView Transitionsは、Chromium系やSafariでの完全対応により、商用プロジェクトでも安心して導入できるフェーズを迎えています。非対応環境でも、Astroが自動で快適なフォールバックを実行してくれるため、Core Web VitalsやSEOへの悪影響を恐れる必要はありません。
最初はJavaScriptの再発火などで戸惑うかもしれませんが、仕組みさえ掴んでしまえば、サイト全体のクオリティを劇的に引き上げることができます。ぜひ導入して、競合サイトに一歩差をつける洗練されたWebサイトを作り上げてください!
あわせて読みたい




