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

ページ遷移で点の格子が押した所から膨らんで画面を埋め、しぼむと次のページになっている

デモ

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

仕様

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

しくみと調整

リンクを押すと、画面に固定した canvas に点の格子を敷き、押した所に近い点から順に膨らませます。点は隣と重なるまで膨らむと画面を一色で覆い、覆いきったところで次のページに差し替えます。そのあと点を同じ順、押した所からしぼませるので、網点のすき間から次のページが現れます。四角いタイルが 1 枚ずつ裏返る遷移や、円が 1 つ広がる遷移と違い、印刷の網点のように点の大きさの勾配が波になって広がり、ページが粒に溶けて入れ替わって見えます。

点ごとの遅れは、押した所からの距離をいちばん遠い角までの距離で割り、0.7 秒を掛けた値です。1 つの点が膨らむのは 0.35 秒の power1.inOut で、遅れの幅より短くしてあるので、小さな点から覆いきった面までの勾配が帯になって進みます。始まりの 0.16 秒で半径 2.5px の小さな点を画面全体に一度に出し、終わりに消すので、格子そのものも見えます。格子は、横の数を間隔の 32px から決めて画面の幅ちょうどに割り、縦は中央にそろえます。

膨らみきった半径は、格子の間隔の 0.707 倍(√2 ÷ 2)より大きくしてください。これより小さいと、4 つの点のあいだに星形のすき間が残り、覆いきらないまま差し替わって見えます。ここでは 0.72 倍にしています。重ねても円の縁のにじみが細い格子の線になって残るので、どの点も膨らみきったら画面を一色で塗ります。点は DOM の要素で作らず、canvas 1 枚に毎回すべて描き直すと、数百個あっても軽く動きます。色と間隔は CSS 変数に置き、JS は getComputedStyle で読みます。

次のページの HTML は、点が覆っているあいだに読み込み、覆いきってから中身を差し替えます。差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。Enter キーで押したリンクのクリックには座標が無いので、そのときはリンクの中央から広げます。ブラウザの「戻る」「進む」では、画面の中央から広げます。

つまみは、格子の間隔、波の速さ、1 つの点が膨らむ時間、点の色です。間隔を広げると粗い網点に、膨らむ時間を長くすると勾配の帯が広くゆるやかになります。狭い画面では間隔を 24px に詰めています。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。印刷物や紙もの、写真やグラフィックを扱う制作会社のように、刷りの質感をページの切り替えにも持たせたいサイトに向いています。

コード

HTML
<!-- 遷移で差し替えるのは data-page の中だけ。次のページ(作品の詳細)も同じ形で書く -->
<div class="page" 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/" aria-current="page">作品</a></li>
        <li><a href="/about/">事務所について</a></li>
        <li><a href="/journal/">読みもの</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="works">
    <p class="works__eyebrow">Works</p>
    <div class="works__head">
      <h1 class="works__title">これまでの仕事</h1>
      <p class="works__count">全12件</p>
    </div>
    <ul class="works__list">
      <li>
        <a class="work-card" href="/works/seaside-inn/">
          <span class="work-card__art work-card__art--sea" role="img" aria-label="月の出た夜の海"></span>
          <span class="work-card__title">海辺の宿のリニューアル</span>
          <span class="work-card__meta">ブランディング・2026年</span>
        </a>
      </li>
      <li>
        <a class="work-card" href="/works/roastery/">
          <span class="work-card__art work-card__art--bag" role="img" aria-label="並んだコーヒー豆の袋"></span>
          <span class="work-card__title">焙煎所のパッケージ</span>
          <span class="work-card__meta">パッケージ・2025年</span>
        </a>
      </li>
      <li>
        <a class="work-card" href="/works/museum/">
          <span class="work-card__art work-card__art--arch" role="img" aria-label="丸い窓のある展示室"></span>
          <span class="work-card__title">美術館の展覧会サイト</span>
          <span class="work-card__meta">Webサイト・2026年</span>
        </a>
      </li>
    </ul>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --dot-color: #688297; /* 網点の色 */
  --dot-gap: 32px; /* 格子の間隔。px で書く(JS が数として読む) */
  --gutter: clamp(24px, 7vw, 96px);
}

@media (max-width: 600px) {
  :root { --dot-gap: 24px; }
}

body { margin: 0; background: var(--page-bg); }
.page { min-height: 100vh; background: var(--page-bg); color: var(--page-ink); }

/* 網点を描く canvas。画面に固定し、下のページのクリックは止めない(JS が付け外しする) */
.dot-screen { position: fixed; inset: 0; z-index: 100; width: 100%; height: 100%; 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; }
.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; }

.works { padding: 48px var(--gutter) 96px; }
.works__eyebrow { margin: 0 0 12px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--page-mute); }
.works__head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 24px; margin-bottom: 40px; }
.works__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.works__count { margin: 0; font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; color: var(--page-mute); }

.works__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 40px 32px; margin: 0; padding: 0; list-style: none; }
.work-card { display: grid; gap: 8px; color: inherit; text-decoration: none; }
.work-card__title { margin-top: 12px; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.work-card__meta { font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--page-mute); }
.work-card:focus-visible { outline: 2px solid currentColor; outline-offset: 6px; }

/* 写真の代わりの抽象画。4:3 の枠なので、横 9%・縦 12% の楕円が真円になる。案件では img に差し替える */
.work-card__art { display: block; aspect-ratio: 4 / 3; border-radius: 4px; }
.work-card__art--sea {
  background:
    radial-gradient(9% 12% at 70% 26%, #d9ba92 0 96%, transparent 100%),
    linear-gradient(90deg, transparent 62%, rgb(217 186 146 / 0.5) 62% 78%, transparent 78%) 0 76% / 100% 3% no-repeat,
    linear-gradient(90deg, transparent 58%, rgb(217 186 146 / 0.32) 58% 82%, transparent 82%) 0 86% / 100% 2% no-repeat,
    linear-gradient(180deg, transparent 62%, #263741 62%),
    radial-gradient(60% 30% at 18% 64%, #3a4e5b 0 98%, transparent 100%),
    linear-gradient(180deg, #1d2429 0%, #2d404c 62%);
}
.work-card__art--bag {
  background:
    radial-gradient(9% 12% at 64% 40%, #f2f1ed 0 96%, transparent 100%),
    linear-gradient(#a98860, #a98860) 30% 72% / 22% 56% no-repeat,
    linear-gradient(#d9ba92, #d9ba92) 66% 74% / 24% 50% no-repeat,
    linear-gradient(180deg, #ebdfcb 0%, #decdb4 100%);
}
.work-card__art--arch {
  background:
    radial-gradient(12% 16% at 68% 34%, #aea0cf 0 96%, transparent 100%),
    radial-gradient(22% 34% at 34% 58%, #857ba5 0 98%, transparent 100%),
    linear-gradient(#857ba5, #857ba5) 34% 100% / 44% 42% no-repeat,
    linear-gradient(180deg, #e2dee9 0%, #d0c9e0 100%);
}

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

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

JavaScript
import { gsap } from "gsap";

const SPREAD = 0.7; // いちばん遠い点が動き出すまでの遅れ(秒)。波の速さ
const GROW = 0.35; // 1 つの点が膨らむ・しぼむ時間(秒)。SPREAD より短いほど、網点の勾配の帯が細くなる
const APPEAR = 0.16; // 始まりに小さな点が出る・終わりに消える時間(秒)
const REACH = 0.72; // 膨らみきった点の半径(格子の間隔に対する比)。0.707 を越えると隣と重なって覆いきる
const SWAP = SPREAD + GROW + 0.02; // ページを差し替える時刻(覆いきった後)
const SHRINK = SWAP + 0.02, LEN = SHRINK + SPREAD + GROW + APPEAR; // しぼみ始める時刻(同じ順にしぼむ)と、遷移の長さ
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const swell = gsap.parseEase("power1.inOut"), clamp01 = (v) => Math.min(1, Math.max(0, v));
let busy = false, 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 };
}

// 画面に固定した canvas と、点の中心の格子を作る。横の数を間隔から決めて、画面の幅ちょうどに割る
function makeScreen() {
  const style = getComputedStyle(document.documentElement), gap = parseFloat(style.getPropertyValue("--dot-gap")) || 32;
  const w = innerWidth, h = innerHeight, dpr = Math.min(devicePixelRatio, 2);
  const canvas = Object.assign(document.createElement("canvas"), { className: "dot-screen", width: Math.round(w * dpr), height: Math.round(h * dpr) });
  const g = canvas.getContext("2d");
  Object.assign(g, { fillStyle: style.getPropertyValue("--dot-color").trim() }).scale(dpr, dpr); // 色は CSS の変数から読む
  const cols = Math.max(1, Math.round(w / gap)), step = w / cols, rows = Math.ceil(h / step) + 1;
  const top = (h - (rows - 1) * step) / 2; // 縦は中央にそろえる
  const dots = Array.from({ length: rows * cols }, (_, i) => [((i % cols) + 0.5) * step, top + Math.floor(i / cols) * step]);
  return { canvas, g, dots, w, h, reach: step * REACH };
}

// 遷移を始めてから e 秒の点を描く。点ごとに「押した所からの距離 ÷ いちばん遠い角まで」だけ遅らせる
function draw({ g, dots, w, h, reach }, e, o) {
  g.clearRect(0, 0, w, h);
  const far = Math.hypot(Math.max(o.x, w - o.x), Math.max(o.y, h - o.y));
  const seed = 2.5 * Math.min(clamp01(e / APPEAR), clamp01((LEN - e) / APPEAR)); // 格子が見える小さな点
  const shrinking = e >= SHRINK;
  let full = !shrinking; // しぼむまでに、どの点も膨らみきっていれば覆いきった
  g.beginPath();
  for (const [x, y] of dots) {
    const lag = (SPREAD * Math.hypot(x - o.x, y - o.y)) / far;
    const p = shrinking ? 1 - swell(clamp01((e - SHRINK - lag) / GROW)) : swell(clamp01((e - lag) / GROW));
    const r = Math.max(seed, reach * p);
    if (r < reach) full = false;
    if (r >= 0.2) { g.moveTo(x + r, y); g.arc(x, y, r, 0, Math.PI * 2); }
  }
  if (full) g.rect(0, 0, w, h); // 膨らみきったら一色で塗る(円の縁のにじみを残さない)
  g.fill();
}

// 遷移の秒数を from から to まで実時間で進める。動きを減らす設定では、同じ流れのままほぼ一瞬で済ませる
function run(screen, o, from, to) {
  const clock = { e: from }; // 秒数を 1 つの値として進め、毎回すべての点を描き直す
  return gsap.to(clock, { e: to, duration: reduceMotion ? 0.05 : to - from, ease: "none", onUpdate: () => draw(screen, clock.e, o) });
}

async function go(url, push, o) {
  if (busy) return;
  busy = true;
  const screen = makeScreen();
  try {
    const loading = load(url); // 覆っているあいだに次のページを読む
    document.body.append(screen.canvas);
    await run(screen, o, 0, SWAP);
    const { next, title } = await loading;
    if (!next) throw new Error("data-page が無い");
    document.querySelector("[data-page]").replaceWith(next); // 差し替えは覆いきってから
    if (push) history.pushState({}, "", url);
    shownPath = new URL(url, location.href).pathname;
    scrollTo(0, 0);
    document.title = title;
    const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
    if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
    await run(screen, o, SWAP, LEN);
  } catch { location.href = url; return; } // 読めなければ、覆ったままふつうに移る
  screen.canvas.remove();
  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();
  const box = link.getBoundingClientRect(); // Enter で押したときは座標が無い(detail が 0)ので、リンクの中央から広げる
  const o = event.detail ? { x: event.clientX, y: event.clientY } : { x: box.left + box.width / 2, y: box.top + box.height / 2 };
  go(url.href, true, o);
});

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

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

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