JavaScript

ページ遷移アニメーションの作り方|View Transition APIとJavaScriptのフェード実装

ページ遷移時にフェードイン・フェードアウトを付ける方法を解説します。View Transition APIによるCSS中心の実装、未対応ブラウザ向けJavaScript、外部・別タブ・ページ内リンクの除外、bfcacheとアクセシビリティ対策まで確認できます。

この記事の目次
  1. 最初に選ぶ実装を決める
  2. MPAは@view-transitionだけで始められる
  3. defaultのcross-fadeを短く調整する
  4. fadeと少しの移動を組み合わせる
  5. logoや画像をshared elementとしてつなぐ
  6. 動きを減らす設定に対応する
  7. WordPressへ追加する方法
  8. 未対応browser向けにJavaScriptでfadeする
  9. fade-in・fade-outのCSS
  10. linkを安全に選別するJavaScript
  11. なぜURLはindexOfでなくURL APIで判定するのか
  12. 外部link・別tab・downloadを止めない
  13. bfcacheから戻ったときの白画面を防ぐ
  14. SPAはdocument.startViewTransitionを使う
  15. barba.jsやGSAPを選ぶ場面
  16. ページ遷移アニメーションが動かない原因
  17. @view-transitionを指定しても変化しない
  18. 画面が白いまま遷移しない
  19. Command + clickやmiddle clickが同じtabで開く
  20. ページ内linkでもfadeしてしまう
  21. 戻るとopacity 0のままになる
  22. animationが重い
  23. 実装チェックリスト
  24. まとめ

ページ遷移アニメーションは、現在では標準の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を追加します。

  1. child themeまたは独自themeの共通stylesheetへCSSを追加する
  2. cache・minifyを更新する
  3. 同一site内の記事・固定page・category間で確認する
  4. 外部site、別subdomain、login redirectは通常遷移になることを確認する
  5. 戻る・進むと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 context
  • download属性付き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が保存されています。pageshowis-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を壊さないように実装してください。