本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ページ遷移

ページ遷移で階層を下りると今のページが左へ押し出され、パンくずに 1 段増える

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

「サービス」→「Web 制作」→「料金」のように階層を下りるリンクが押されたら、次のページを読み込み、今のページと同じ枠に右側から重ねます。今のページは左へ 25% ずれながら黒い幕で暗くなり、次のページが右から押し出すように入ってきます。同時に、上のパンくずの末尾に新しい段が右から滑り込み、1 つ前の段は現在地の色からリンクの色へ移ります。戻るときは同じ道を逆にたどり、今のページが右へ抜け、左で暗くなっていたページが明るくなりながら戻って、パンくずの末尾の段が右へ抜けます。下から重なって滑り込む遷移や、メニューの中だけで階層が入れ替わる動きと違い、ページ全体が横に押し出され、ヘッダーとパンくずの行は動かないので、今どの深さにいるかが目で追えます。

入れ替わりは 0.7 秒の power3.inOut で、今のページのずれと暗さも同じ時間・同じ ease にそろえています。押し出されるページは幅の 25% しかずらさず、幕も 0.35 までにとどめるので、奥に前のページが残っているように見えます。パンくずの新しい段は、0.25 秒遅れて 0.45 秒の power3.out で右 24px から入り、ページが収まるころに止まります。戻るときの段は 0.3 秒の power2.in で右へ抜け、2 段上へ戻るときは後ろの段から 0.05 秒ずつずらします。

下りるか戻るかは、次のページのパンくずの段の数で決めます。今より段が多ければ下りる、少なければ戻るとみなすので、リンクごとに向きを書かなくて済み、ブラウザの戻る・進むでも同じ決まりで動きます。2 段上へ戻るときは、あいだのページは出さず、戻り先のページだけを左から戻します。上に来るページの左の縁には薄い影を付けて、重なりの境目を見せています。幕は奥になるページに重ねた疑似要素で、その濃さを --veil という CSS 変数で動かすと、色を CSS に残したまま GSAP で補間できます。

パンくずは、同じ所までの段はそのまま残し、増えた段・減った段だけを動かします。枠の中の 2 枚のページは同じマスに重ね、はみ出しは overflow: clip で切ります。hidden で切ると、焦点が移ったときに枠が横へスクロールすることがあります。入れ替えたらスクロールを頭に戻し、読み上げとキーボードの位置を新しいページの見出しへ移します。読み込めなかったときは、ふつうのページ移動に任せます。

つまみは、入れ替わりの時間、奥になるページのずれと幕の濃さ、パンくずの段の遅れです。ずれを 0 にすると上に重なるだけの形に、100% にすると 2 枚が並んで送られる形になります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。サービスの紹介から詳細・料金へ進むページや、商品の分類から個別のページへ下りるサイトのように、階層の深さを伝えたい遷移に向いています。

コード

HTML
<!-- ヘッダーとパンくずは動かさない。入れ替えるのは data-page の中だけ。次のページも同じ形で書き、パンくずの段の数で下りるか戻るかを決める -->
<header class="site-header">
  <a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
  <nav aria-label="メイン">
    <ul class="site-header__links">
      <li><a href="/works/">作品</a></li>
      <li><a href="/service/" aria-current="page">サービス</a></li>
      <li><a href="/about/">事務所について</a></li>
      <li><a href="/contact/">お問い合わせ</a></li>
    </ul>
  </nav>
</header>

<nav class="crumbs" aria-label="現在地">
  <ol class="crumbs__list" data-crumbs>
    <li class="crumbs__item"><a href="/service/" aria-current="page">サービス</a></li>
  </ol>
</nav>

<div class="stage" data-stage>
  <main class="page" data-page>
    <p class="page__label">Service</p>
    <h1 class="page__title">サービス</h1>
    <p class="page__lead">ここに説明文が入ります。</p>
    <ul class="cards">
      <li class="card">
        <!-- 写真の代わりの丘と日。案件では img に差し替える -->
        <span class="card__thumb" style="--c: #62839d"></span>
        <h2 class="card__title"><a class="card__link" href="/service/web/">Web 制作</a></h2>
        <p class="card__text">ここに説明文が入ります。</p>
        <span class="card__more" aria-hidden="true">詳しく見る →</span>
      </li>
      <li class="card">
        <span class="card__thumb" style="--c: #a98860"></span>
        <h2 class="card__title"><a class="card__link" href="/service/photo/">写真撮影</a></h2>
        <p class="card__text">ここに説明文が入ります。</p>
        <span class="card__more" aria-hidden="true">詳しく見る →</span>
      </li>
      <li class="card">
        <span class="card__thumb" style="--c: #6f9770"></span>
        <h2 class="card__title"><a class="card__link" href="/service/support/">運用の相談</a></h2>
        <p class="card__text">ここに説明文が入ります。</p>
        <span class="card__more" aria-hidden="true">詳しく見る →</span>
      </li>
    </ul>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --page-sep: #898989;
  --surface: #ffffff;
  --on-ink: #ffffff;
  --line: rgb(38 38 38 / 0.12);
  --veil-color: #111111; /* 奥になったページにかける幕 */
  --edge: -16px 0 40px rgb(17 17 17 / 0.14); /* 上に来るページの左の縁の影 */
  --gutter: clamp(24px, 7vw, 96px);
}

body { margin: 0; background: var(--page-bg); color: var(--page-ink); }

.site-header { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; padding: 32px var(--gutter) 24px; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.site-header__logo { margin-right: auto; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap; }
.site-header__links { display: flex; flex-wrap: wrap; gap: 8px 32px; margin: 0; padding: 0; list-style: none; }
.site-header a { color: var(--page-mute); text-decoration: none; }
.site-header .site-header__logo,
.site-header a[aria-current="page"] { color: var(--page-ink); }
.site-header a[aria-current="page"] { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 6px; }
a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

/* パンくず。区切りの「/」は段の中に描くので、段と一緒に滑る */
.crumbs { padding: 0 var(--gutter) 16px; font-size: 13px; font-weight: 600; letter-spacing: 0.04em; }
.crumbs__list { display: flex; flex-wrap: wrap; margin: 0; padding: 0; list-style: none; }
.crumbs__item { white-space: nowrap; }
.crumbs__item + .crumbs__item::before { content: "/"; padding: 0 12px; color: var(--page-sep); }
.crumbs a { color: var(--page-mute); text-decoration: none; transition: color 0.3s; }
.crumbs a[aria-current="page"] { color: var(--page-ink); pointer-events: none; }

/* ページの枠。遷移のあいだは 2 枚のページを同じマスに重ね、はみ出しは clip で切る(hidden だと焦点で横にずれる) */
.stage { display: grid; overflow: clip; border-top: 1px solid var(--line); }
.page { grid-area: 1 / 1; position: relative; min-width: 0; padding: 32px var(--gutter) 96px; background: var(--page-bg); }
.page::after { content: ""; position: absolute; inset: 0; background: var(--veil-color); opacity: var(--veil, 0); pointer-events: none; }
.page.is-top { z-index: 1; box-shadow: var(--edge); }

.page__label { margin: 0 0 12px; font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--page-mute); }
.page__title { margin: 0; font-size: clamp(36px, 5vw, 56px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; }
.page__title:focus { outline: none; } /* 読み上げの位置を移すためだけの焦点なので、枠は出さない */
.page__lead { max-width: 36em; margin: 16px 0 32px; font-size: 16px; line-height: 1.8; letter-spacing: 0.04em; }

.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 24px 64px; margin: 0; padding: 0; list-style: none; }
.card { position: relative; overflow: hidden; border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
.card__title { margin: 24px 24px 8px; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.card__link { color: inherit; text-decoration: none; }
.card__link::after { content: ""; position: absolute; inset: 0; } /* カード全体を押せるように */
.card__link:focus-visible { outline: none; }
.card:has(.card__link:focus-visible) { outline: 2px solid var(--page-ink); outline-offset: 4px; }
.card__text { margin: 0 24px; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--page-mute); }
.card__more { display: block; margin: 16px 24px 24px; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }

/* 写真の代わりの丘と日。案件では img に差し替える */
.card__thumb { display: block; aspect-ratio: 2 / 1; }
.page__art { aspect-ratio: 27 / 26; border-radius: 4px; }
.card__thumb,
.page__art {
  background:
    radial-gradient(circle 18px at 72% 30%, color-mix(in srgb, var(--c) 30%, #ffffff) 0 94%, transparent 100%),
    radial-gradient(80% 62% at 22% 108%, var(--c) 0 98%, transparent 100%),
    radial-gradient(70% 52% at 88% 112%, color-mix(in srgb, var(--c) 70%, var(--page-ink)) 0 98%, transparent 100%),
    color-mix(in srgb, var(--c) 40%, var(--page-bg));
}

.button { display: inline-flex; align-items: center; height: 48px; padding: 0 32px; border-radius: 999px; background: var(--page-ink); color: var(--on-ink); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
.button:focus-visible { outline-color: var(--page-ink); }

/* 下の階層のページ(Web 制作・料金)の部品 */
.page__body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 432px); gap: 48px; align-items: start; }
.features { max-width: 512px; margin: 0 0 32px; padding: 0; list-style: none; }
.features li { display: flex; align-items: center; gap: 12px; min-height: 44px; box-shadow: inset 0 1px 0 var(--line); font-size: 16px; letter-spacing: 0.04em; }
.features li::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--page-ink); }
.plans { margin: 0; padding: 0; list-style: none; box-shadow: inset 0 -1px 0 var(--line); }
.plan { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; min-height: 96px; box-shadow: inset 0 1px 0 var(--line); }
.plan__name { margin: 0 0 8px; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.plan__text { margin: 0; font-size: 14px; letter-spacing: 0.04em; color: var(--page-mute); }
.plan__price { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }

@media (max-width: 760px) {
  .page__body { grid-template-columns: minmax(0, 1fr); }
}

JavaScript
import { gsap } from "gsap";

const PUSH = { duration: 0.7, ease: "power3.inOut" }; // ページが入れ替わる。ずれと幕も同じ時間・同じ ease にそろえる
const BACK = -25; // 奥になるページのずれ(幅に対する %)
const VEIL = 0.35; // 奥になるページの幕の濃さ
const CRUMB_IN = { x: 24, opacity: 0, duration: 0.45, ease: "power3.out" }; // 足した段は、少し遅れて右から滑り込む
const CRUMB_OUT = { x: 24, opacity: 0, duration: 0.3, ease: "power2.in", stagger: 0.05 }; // 減った段は、後ろから右へ抜ける
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const stage = document.querySelector("[data-stage]");
const trail = document.querySelector("[data-crumbs]");
let busy = false;
let shownPath = location.pathname; // いま見せているページ

history.scrollRestoration = "manual"; // スクロール位置は、入れ替えたあとに自分で戻す

// 次のページの HTML を読み、入れ替える部分(data-page)・パンくずの段・タイトルを取り出す
async function load(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(response.statusText);
  const doc = new DOMParser().parseFromString(await response.text(), "text/html");
  const next = doc.querySelector("[data-page]");
  const crumbs = doc.querySelector("[data-crumbs]");
  if (!next || !crumbs) throw new Error("data-page か data-crumbs が無い");
  return { next, crumbs: [...crumbs.children], title: doc.title };
}

async function swap(url, push) {
  const { next, crumbs, title } = await load(url);
  if (push) history.pushState(null, "", url);
  shownPath = location.pathname;
  const current = stage.querySelector("[data-page]");
  const old = [...trail.children];
  const deeper = crumbs.length >= old.length; // 段が減るなら戻る。それ以外(増える・同じ深さ)は下りる向き
  // 同じ所までの段は残し、現在地の印だけを付け替える(色は CSS の transition で移る)
  let same = 0;
  while (same < Math.min(old.length, crumbs.length) && old[same].textContent === crumbs[same].textContent) same++;
  old.slice(0, same).forEach((item, i) => {
    const link = item.querySelector("a");
    if (crumbs[i].querySelector("[aria-current]")) link.setAttribute("aria-current", "page");
    else link.removeAttribute("aria-current");
  });
  const added = crumbs.slice(same);
  const removed = old.slice(same).reverse(); // 後ろの段から抜く
  trail.append(...added);

  // 下りるときは次のページを上に、戻るときは下に重ねる。スクロールしていたら、次のページを今見えている高さに合わせる
  const lift = Math.max(0, scrollY - stage.offsetTop);
  const keep = scrollY - lift; // 入れ替えたあとのスクロール位置(次のページの頭が、今と同じ高さに来る)
  next.style.marginTop = `${lift}px`;
  deeper ? stage.append(next) : stage.prepend(next);
  (deeper ? next : current).classList.add("is-top");
  stage.inert = true; // 動いているあいだ、焦点が入らないように

  const tl = gsap.timeline({ defaults: PUSH }).timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で
  if (deeper) tl.fromTo(next, { xPercent: 100 }, { xPercent: 0 }, 0).fromTo(current, { xPercent: 0, "--veil": 0 }, { xPercent: BACK, "--veil": VEIL }, 0);
  else tl.fromTo(current, { xPercent: 0 }, { xPercent: 100 }, 0).fromTo(next, { xPercent: BACK, "--veil": VEIL }, { xPercent: 0, "--veil": 0 }, 0);
  if (removed.length) tl.to(removed, CRUMB_OUT, 0);
  if (added.length) tl.from(added, CRUMB_IN, 0.25);
  await tl;

  removed.forEach((item) => item.remove());
  current.remove();
  gsap.set(next, { clearProps: "transform,--veil,marginTop" });
  next.classList.remove("is-top");
  stage.inert = false;
  document.title = title;
  scrollTo(0, keep);
  const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
}

function go(url, push) {
  if (busy) return;
  busy = true;
  swap(url, push)
    .catch(() => (location.href = url)) // 読み込めなかったら、ふつうに移る
    .finally(() => (busy = false));
}

// ページの中とパンくずのリンクだけ横取りする(ヘッダーのリンクは階層の移動ではないので、ふつうに移る)
document.addEventListener("click", (event) => {
  const link = event.target.closest("a[href]");
  if (!link || event.defaultPrevented || event.button !== 0 || link.closest(".site-header")) return;
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target || link.hasAttribute("download")) return;
  const url = new URL(link.href);
  if (url.origin !== location.origin || url.pathname === shownPath) return;
  event.preventDefault();
  go(url.href, true);
});

// ブラウザの戻る・進むでも、パンくずの段の数で向きを決めて動かす
addEventListener("popstate", () => location.pathname !== shownPath && go(location.href, false));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。