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

ページ遷移でクリック位置から円が広がって次のページが現れる

デモ

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

仕様

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

しくみと調整

リンクを押すと、次のページの HTML を読み込んで今のページの上に重ね、押した位置を中心にした円で切り抜いて見せます。円の半径を 0 から広げていき、画面を覆いきったところで、重ねていたページと今のページを入れ替えます。切り抜きは clip-path の circle で、GSAP はこの指定の文字列をそのまま補間できます。

円が画面を覆いきるまでは 1 秒、イージングは power2.inOut です。押した直後はゆっくり開き始めるので、どこから開くのかを目で追えます。同時に、前のページは 0.94 倍へ縮めて黒い幕で半分ほど暗くし、次のページは押した位置を中心に 1.08 倍から等倍へ 1.3 秒かけて引きます。奥へ沈むページと手前に落ち着くページの差で、円の縁がいっそう読みやすくなります。

つまずきやすいのは、円の中心と半径です。半径は、押した位置から画面のいちばん遠い角までの距離(Math.hypot)にします。決め打ちの値では、端のほうを押したとき、反対側の角が覆われずに残ります。また、Enter キーで押したリンクのクリックには座標が無く(0, 0)、そのままでは円がいつも左上の角から開くので、キーボードで押されたときはリンクの中央を中心にします。

前のページを縮める中心は、ページ全体の中央ではなく、いま見えている範囲の中央に置きます。長いページを下までスクロールしてから押しても、見えている部分がその場で奥へ沈みます。入れ替えは、円が画面を覆いきってから行います。読み込んだページの中のスクリプトは動かないので、ページごとの初期化は、入れ替えたあとに呼び直してください。

つまみは、円が開く時間とイージング、前のページを縮める量と暗くする量です。2 つのページの地の明るさを分けておくと、円の縁がはっきり読めます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。商品一覧やポートフォリオのように、押した所から次の画面が広がるように見せたいサイトのページ遷移に向いています。

コード

HTML
<!-- 遷移で差し替えるのは data-page の中だけ。次のページも同じ形で書き、地の色はページのクラスで変える -->
<div class="page page--paper" 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="/shop/">ショップ</a></li>
        <li><a href="/journal/">読みもの</a></li>
        <li><a href="/about/">私たちについて</a></li>
      </ul>
    </nav>
  </header>

  <main class="hero">
    <p class="hero__eyebrow">New season — 2027</p>
    <h1 class="hero__title">ゆっくり過ごす<br>朝のための器</h1>
    <p class="hero__lead">ここに説明文が入ります。</p>
    <a class="button" href="/shop/">コレクションを見る <span aria-hidden="true">→</span></a>
  </main>
</div>

CSS
:root {
  --depth: #000000; /* 縮んだページの周りに見える奥の地と、沈むページにかける幕の色 */
  --gutter: clamp(24px, 7vw, 96px);
}

/* ページの色はページごとに持つ。明暗を分けておくと、円の縁がはっきり読める */
.page {
  min-height: 100vh;
  background: var(--page-bg);
  color: var(--page-ink);
}

.page--paper { --page-bg: #f2f1ed; --page-ink: #262626; --page-mute: #6b6b6b; --button-bg: #262626; --button-ink: #ffffff; }
.page--night { --page-bg: #111111; --page-ink: #eeece6; --page-mute: #a8a6a0; --button-bg: #eeece6; --button-ink: #111111; }

/* 遷移のあいだは、縮んだページの周りを奥の地の色にする */
.is-revealing { background: var(--depth); }
.is-revealing body { background: none; }

/* 次のページを重ねる層と、今のページにかける幕(どちらも JS が作って、終わったら消す) */
.reveal-layer {
  position: fixed;
  inset: 0;
  z-index: 100;
  overflow: hidden;
}

.reveal-shade {
  position: fixed;
  inset: 0;
  z-index: 99;
  background: var(--depth);
  pointer-events: none;
}

.site-header {
  display: flex;
  flex-wrap: wrap; /* 狭い画面では、リンクをロゴの下の行へ送る */
  align-items: center;
  gap: 16px 32px;
  padding: 32px var(--gutter);
  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; white-space: nowrap; }
.site-header a { color: inherit; text-decoration: none; }

.hero { padding: 96px var(--gutter); }

.hero__eyebrow {
  margin: 0 0 24px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--page-mute);
}

.hero__title { margin: 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.hero__lead { margin: 24px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

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

.page a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

/* 遷移のあとに見出しへ移したフォーカスは、枠を出さない(読み上げの位置を移すためだけのもの) */
.page h1[tabindex="-1"]:focus { outline: none; }

JavaScript
import { gsap } from "gsap";

const REVEAL = 1; // 円が画面を覆いきるまで(秒)
const EASE = "power2.inOut";
const SINK = 0.94; // 前のページが縮む先
const SHADE = 0.5; // 前のページにかける幕の濃さ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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");
  return { next: doc.querySelector("[data-page]"), title: doc.title };
}

// (x, y) から円を広げて次のページを見せ、開ききったら今のページと入れ替える
async function reveal(url, x, y, push) {
  const { next, title } = await load(url);
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  const current = document.querySelector("[data-page]");
  const layer = Object.assign(document.createElement("div"), { className: "reveal-layer" });
  const shade = Object.assign(document.createElement("div"), { className: "reveal-shade" });
  layer.append(next);
  document.body.append(shade, layer);
  document.documentElement.classList.add("is-revealing");

  // いちばん遠い角までの距離。ここまで広げると、どこを押しても画面を覆いきる
  const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
  // 前のページは、ページ全体の中央ではなく、いま見えている範囲の中央へ縮める
  const middle = innerHeight / 2 - current.getBoundingClientRect().top;
  await gsap
    .timeline()
    .timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
    .fromTo(layer, { clipPath: `circle(0px at ${x}px ${y}px)` }, { clipPath: `circle(${radius}px at ${x}px ${y}px)`, duration: REVEAL, ease: EASE })
    .fromTo(next, { scale: 1.08, transformOrigin: `${x}px ${y}px` }, { scale: 1, duration: 1.3, ease: "expo.out" }, 0)
    .fromTo(current, { scale: 1, transformOrigin: `50% ${middle}px` }, { scale: SINK, duration: 0.8, ease: "power2.out" }, 0)
    .fromTo(shade, { opacity: 0 }, { opacity: SHADE, duration: 0.8, ease: "power2.out" }, 0);

  gsap.set(next, { clearProps: "transform,transformOrigin" });
  current.replaceWith(next);
  layer.remove();
  shade.remove();
  document.documentElement.classList.remove("is-revealing");
  document.title = title;
  scrollTo(0, 0);
  // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  const heading = next.querySelector("h1");
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
}

async function go(url, x, y, push) {
  if (busy) return;
  busy = true;
  try {
    await reveal(url, x, y, 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) 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 === location.pathname) return;
  event.preventDefault();
  // Enter で押したときは座標が無い(0, 0)ので、リンクの中央から開く
  const box = link.getBoundingClientRect();
  const byKey = event.detail === 0;
  const x = byKey ? box.left + box.width / 2 : event.clientX;
  const y = byKey ? box.top + box.height / 2 : event.clientY;
  go(url.href, x, y, true);
});

// ブラウザの戻る・進むでは、画面の中央から開く(同じページの中の移動は除く)
addEventListener("popstate", () => {
  if (location.pathname !== shownPath) go(location.href, innerWidth / 2, innerHeight / 2, false);
});

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

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