ページ遷移アニメーションは、現在では標準のView Transition APIを使うと、同一originの複数ページ間でもCSS中心で実装できます。
@view-transition {
navigation: auto;
}
この指定を共通CSSへ追加すると、対応browserでは同一site内のnavigationにdefaultのcross-fadeが適用されます。未対応browserはこの規則を無視して通常どおり遷移するため、JavaScriptでlinkを止める必要はありません。
この記事では、複数HTMLやWordPressなどのMPA、ReactなどのSPA、未対応browser向けのJavaScript fallbackを分けて説明します。フェードイン・フェードアウト、shared element、外部linkや別tabの除外、browser back、bfcache、動きを減らす設定まで実装できます。
最初に選ぶ実装を決める
ページ遷移の方式によって使うAPIが異なります。
| site構成 | 第一選択 | 特徴 |
|---|---|---|
| 複数HTML・WordPress・通常のserver rendering | @view-transition |
同一originのdocument間をCSS中心でanimation |
| React・Vueなどで同一documentのDOMを更新 | document.startViewTransition() |
DOM更新functionをsnapshot間に実行 |
| 未対応browserでもfadeを必須にする | CSS animation + click delegation | linkを選別してanimation後にnavigation |
| data fetch・persistent layout・複雑な演出 | router / barba.js + GSAP | lifecycle管理が必要だが自由度が高い |
迷った場合、まず標準APIを段階的強化として追加してください。未対応環境でanimationが省略されても、link本来のnavigationは維持されます。
MPAは@view-transitionだけで始められる
通常のHTML siteやWordPressは、linkを押すと新しいdocumentを読み込むMPAです。現在pageと遷移先pageの両方が次のCSSを読み込むようにします。
@view-transition {
navigation: auto;
}
cross-documentのView Transitionが始まる主な条件は次のとおりです。
- 遷移元と遷移先が同一originである
- 両方のdocumentが
@view-transitionでopt-inしている - link clickなど、user操作から始まる対象navigationである
- 遷移途中にcross-origin redirectを挟まない
- browserがcross-document View Transitionに対応している
originにはscheme・hostname・portが含まれます。subdomainやportが異なる場合は同一originではありません。
defaultのcross-fadeを短く調整する
page全体はrootというsnapshot名で扱われます。oldは遷移前、newは遷移先のsnapshotです。
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 280ms;
animation-timing-function: ease;
}
最初は200〜400ms程度に抑えると、操作を待たされる感覚が出にくくなります。
fadeと少しの移動を組み合わせる
default animationを置き換える場合は、oldとnewを別々に指定します。
@keyframes page-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
@keyframes page-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
::view-transition-old(root) {
animation: 220ms ease both page-out;
}
::view-transition-new(root) {
animation: 280ms ease both page-in;
}
opacityと小さなtransformに絞ると、layoutを大きく動かさず実装できます。keyframesの基本はCSS animationの作り方で詳しく解説しています。
logoや画像をshared elementとしてつなぐ
page全体とは別に同じ要素を連続して見せたい場合は、遷移元と遷移先の要素へ同じview-transition-nameを指定します。
<a class="site-logo" href="/">DEVSAKASO</a>
.site-logo {
view-transition-name: site-logo;
}
::view-transition-group(site-logo) {
animation-duration: 320ms;
animation-timing-function: ease-out;
}
同じview-transition-nameを1つのdocument内で複数要素へ重複させないでください。記事一覧のthumbnailと記事detailのheroをつなぐ場合は、遷移対象だけに一意の名前を与える設計が必要です。
動きを減らす設定に対応する
大きな移動やscaleは、利用者によっては負担になります。OSで「視差効果を減らす」「アニメーションを減らす」などを選んだ環境ではanimationを無効にします。
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
装飾目的のanimationは省略しても、情報やnavigationを失わない作りにします。
WordPressへ追加する方法
WordPressでは、遷移元と遷移先の両方が読むthemeのstyle.cssなどへ@view-transitionとpseudo-elementのCSSを追加します。
- child themeまたは独自themeの共通stylesheetへCSSを追加する
- cache・minifyを更新する
- 同一site内の記事・固定page・category間で確認する
- 外部site、別subdomain、login redirectは通常遷移になることを確認する
- 戻る・進むとreduced motionを確認する
標準のcross-document transitionだけなら、全linkをJavaScriptで監視する必要はありません。未対応browserではanimationなしで遷移します。
未対応browser向けにJavaScriptでfadeする
すべての対象browserでfadeを必須にするときだけ、link clickを選別するfallbackを追加します。まず、page本体を1つの要素で囲みます。
<body>
<header>...</header>
<main class="page-transition">
...
</main>
</body>
fade-in・fade-outのCSS
page load時はpage-enter、遷移直前はpage-leaveを実行します。
@keyframes page-enter {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes page-leave {
from { opacity: 1; }
to { opacity: 0; }
}
.page-transition {
animation: 280ms ease both page-enter;
}
.page-transition.is-leaving {
pointer-events: none;
animation: 220ms ease both page-leave;
}
@media (prefers-reduced-motion: reduce) {
.page-transition,
.page-transition.is-leaving {
animation: none;
}
}
linkを安全に選別するJavaScript
次の実装はevent delegationを使うため、後から追加されたlinkも扱えます。外部origin、別tab、download、修飾key、中click、同一page内のhashは通常動作のまま残します。
const page = document.querySelector(".page-transition");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let navigating = false;
function shouldSkipTransition(event, link, url) {
const sameDocumentHash =
url.pathname === window.location.pathname &&
url.search === window.location.search &&
url.hash !== "";
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
(link.target && link.target !== "_self") ||
link.hasAttribute("download") ||
url.origin !== window.location.origin ||
url.href === window.location.href ||
sameDocumentHash
);
}
document.addEventListener("click", (event) => {
if (!page || navigating || reduceMotion.matches) return;
if (!(event.target instanceof Element)) return;
const link = event.target.closest("a[href]");
if (!link) return;
const url = new URL(link.href, window.location.href);
if (shouldSkipTransition(event, link, url)) return;
event.preventDefault();
navigating = true;
page.classList.add("is-leaving");
let didNavigate = false;
const navigate = () => {
if (didNavigate) return;
didNavigate = true;
window.location.assign(url.href);
};
const onAnimationEnd = (animationEvent) => {
if (
animationEvent.target === page &&
animationEvent.animationName === "page-leave"
) {
page.removeEventListener("animationend", onAnimationEnd);
navigate();
}
};
page.addEventListener("animationend", onAnimationEnd);
// animationendが発火しない場合でもnavigationを止めない
window.setTimeout(navigate, 500);
});
animationendを待つことで、CSSのdurationを変更してもJavaScriptの固定delayとずれにくくなります。timeoutはanimationがcancelされた場合の保険です。
event listenerとdelegationの基本はaddEventListenerの使い方も参照してください。
なぜURLはindexOfでなくURL APIで判定するのか
旧来の実装では、次のような判定が使われることがあります。
if (url.indexOf(window.location.hostname)) {
// 遷移処理
}
indexOf()は見つからないと-1を返しますが、JavaScriptでは-1はtruthyです。先頭で見つかった0はfalsyなので、条件が意図と逆になる場合があります。
const url = new URL(link.href, window.location.href);
if (url.origin === window.location.origin) {
// 同一scheme・hostname・port
}
URL objectならrelative URLもabsolute URLへ解決でき、origin・pathname・search・hashを分けて比較できます。
外部link・別tab・downloadを止めない
link本来の操作を保つため、少なくとも次の場合はpreventDefault()より前に処理を抜けます。
- Command / Control / Shift / Altを押したclick
- middle buttonなどprimary button以外
target="_blank"など別のbrowsing contextdownload属性付きlink- 外部origin、mailto、telなど通常page navigation以外
- 現在page内のhash link
- 他の処理ですでに
defaultPreventedになったevent
無条件にstopPropagation()を呼ぶ必要もありません。menu、analytics、別componentのevent処理まで止める可能性があるためです。
bfcacheから戻ったときの白画面を防ぐ
browserの戻る・進むでは、documentがbfcacheから復元される場合があります。遷移前のis-leavingが残っていると、opacity 0の状態が再表示される可能性があります。
window.addEventListener("pageshow", () => {
navigating = false;
page?.classList.remove("is-leaving");
});
強制reloadではなく、遷移用の一時stateを戻すのが基本です。reloadするとbfcacheによる高速な復元を失い、通信やscroll位置へ影響する場合があります。詳しい仕組みはSafariのbrowser backとbfcache対策で整理しています。
SPAはdocument.startViewTransitionを使う
同一document内でDOMを差し替えるSPAでは、@view-transition { navigation: auto; }ではなく、更新functionをdocument.startViewTransition()へ渡します。
function updateView() {
document.querySelector("main").replaceChildren(nextContent);
}
if (!document.startViewTransition) {
updateView();
} else {
document.startViewTransition(updateView);
}
browserは更新前のsnapshotを取得し、callbackでDOMを更新したあと、新しいsnapshotとの間をanimationします。未対応browserではupdateView()を直接実行し、機能自体を維持します。
ReactやVueではrouterとrender cycleの完了を待つ必要があります。frameworkの公式router integrationがある場合は、そのlifecycleに合わせてください。
barba.jsやGSAPを選ぶ場面
標準APIだけでなくlibraryが向くのは、次のような場合です。
- 遷移前後のdata fetchやDOM差し替えを細かく管理する
- headerやplayerをpage間で永続化する
- animation timelineをscrollや複数要素と同期する
- routeごとにenter / leaveの演出を大きく変える
この場合はanimationだけでなく、history、script再初期化、focus、analytics、error時の復旧も設計します。実例はbarba.jsとGSAPのページ遷移に分離しています。
ページ遷移アニメーションが動かない原因
@view-transitionを指定しても変化しない
遷移元だけでなく、遷移先も同じCSSを読み込んでいるか確認します。別origin、cross-origin redirect、reload、未対応browserでは通常遷移になります。progressive enhancementなので、animationがなくてもerrorではありません。
画面が白いまま遷移しない
JavaScript fallbackでpreventDefault()したあと、animation終了時のnavigationが実行されていない可能性があります。animation-name、class名、animationendとfallback timeoutを確認してください。
pageを初期状態からopacity 0にし、JavaScriptだけで表示classを付ける構成は、script error時に本文が見えなくなります。通常状態は表示し、animationを段階的に上乗せする設計が安全です。
Command + clickやmiddle clickが同じtabで開く
修飾keyとevent.buttonを確認する前にpreventDefault()しています。通常browser操作を先に除外してください。
ページ内linkでもfadeしてしまう
href文字列に#があるかだけでなく、URLのpathname・searchが現在pageと同じでhashがあるかを判定します。別pageのheadingへのlinkは通常のpage navigationなので、要件に応じてanimation対象にできます。
戻るとopacity 0のままになる
bfcacheへ遷移中classが保存されています。pageshowでis-leavingを削除し、local UI stateを復元してください。
animationが重い
durationを短くし、page全体のfilterや大きなblurを避け、まずopacityと小さなtransformで確認します。画像寸法を確保してlayout shiftを減らすことも重要です。
実装チェックリスト
- MPAとSPAのどちらかを確認した
- MPAでは両documentが
@view-transitionを読む - 未対応browserでも通常navigationが動く
- animationは200〜400ms程度から調整した
prefers-reduced-motionへ対応した- 外部・別tab・download・修飾keyを妨げない
- 同一pageのhash linkを妨げない
- bfcache復元時に一時classを削除する
- animation終了eventとtimeoutの両方で遷移を保証する
- keyboard、touch、戻る・進むでも確認した
まとめ
通常のHTML siteやWordPressのページ遷移アニメーションは、まず@view-transition { navigation: auto; }を共通CSSへ追加する方法が適しています。同一originの対応browserではcross-fadeが動き、未対応browserでは通常navigationへ自然に戻ります。
SPAはdocument.startViewTransition()でDOM更新を囲みます。JavaScript fallbackが必要な場合は、URL APIとevent delegationを使い、外部link、別tab、download、修飾key、hash、bfcacheを壊さないように実装してください。