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

ページ遷移で次のページ名の巨大な文字が穴になって広がり、中から次のページが現れる

デモ

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

仕様

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

しくみと調整

リンクが押されたら、行き先のページ名を mask で抜いた色面と、その下に敷いた次のページの複製を、ひとつの層にまとめて上から降ろします。降りきったところで本物のページを差し替え、抜き穴になった文字を、字の縦画 1 本を中心にして拡大します。縦画が画面の幅を越えると色面は残らず抜け、次のページだけになります。ページ名の帯が流れる遷移や、押した所から円が広がる遷移と違い、行き先の名前を大きく読ませたまま、その字の中へ入っていく切り替えになります。

色面が降りるのは 0.495 秒の power3.inOut、覆いきってから 0.18 秒おいて、0.612 秒の power1.in で穴を広げます。読みごろのときの文字の幅は画面の 86% で、名前の長さが変わっても同じ幅に収まるよう、字の大きさは測った送り幅から決めています。穴を広げるときは、倍率を足し算ではなく掛け算で増やします。足し算で増やすと、画面に近づくほど遅くなったように見えるためです。

くぐり抜ける縦画の中心と太さ、画面を覆いきる倍率は、実際の字形を測って決めます。和文は字の幅と縦画の太さが違うので、くぐる字を canvas に 1 字だけ描き、字の上下の中ほどの行で塗られている幅を数えます。測るのは fonts.ready を待ってからにしてください。読み込みの前に測ると、代わりの書体の幅で決まり、縦画から外れた所へ向かって穴が広がります。mask の中に入れた文字は測れないので、いったん描かれる場所に置いて測り、測り終えてから mask へ移します。名前は、I や T、和文なら工・小・山・川のように、字の中央を縦画が貫き、そのすぐ横にほかの画が無い字を含むものを選びます。

色面と次のページの複製は、必ず同じ層に入れて一緒に降ろします。別々にして途中でページを差し替えると、穴の中の中身が入れ替わるのが見えてしまいます。色面の色は、ページの地と明暗が離れるほど文字が読みやすくなります。倍率が 24 倍を越えたら、字を縦画と同じ位置・幅の四角に替えて広げます。字の大きさが 1 万 px ほどになると、ブラウザが字を描かなくなったり、ずれた所に描いたりするためです。この倍率では縦画の中ほどしか画面に見えていないので、替えても見た目は変わりません。複製には同じ id が 2 つできるので外し、層に inert を付けて焦点や読み上げが入らないようにしてください。差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。

つまみは、降りる時間と広げる時間、覆いきってから待つ間、文字の幅、色面の色、そしてページ名とくぐる字の番号です。縦画の太さは描いて測るので、書体を替えても測り直しは要りません。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。読み物・雑誌・ブランドのサイトのように、行き先の名前そのものを見せて切り替えたいページ遷移に向いています。

コード

HTML
<!-- 遷移で差し替えるのは data-page の中だけ。次のページも同じ形で書く。
     data-name はこのページへ移るときに穴になる名前、data-stem はくぐり抜ける字の番号(0 から数える。中央を縦画が貫き、すぐ横にほかの画が無い字) -->
<div class="page" data-page data-name="WRITING" data-stem="4">
  <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="/work/">制作実績</a></li>
        <li><a href="/journal/" aria-current="page">工房だより</a></li>
        <li><a href="/about/">私たちについて</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="index">
    <p class="index__eyebrow">Journal — 2026</p>
    <h1 class="index__title">ものづくりの覚え書き</h1>
    <ul class="posts">
      <li><a class="posts__link" href="/journal/small-book/">
        <time class="posts__date" datetime="2026-09-12">2026年9月12日</time>
        <span class="posts__title">小さな本をつくる</span>
      </a></li>
      <li><a class="posts__link" href="/journal/ink-and-paper/">
        <time class="posts__date" datetime="2026-08-04">2026年8月4日</time>
        <span class="posts__title">インクと紙と時間</span>
      </a></li>
      <li><a class="posts__link" href="/journal/grid/">
        <time class="posts__date" datetime="2026-06-28">2026年6月28日</time>
        <span class="posts__title">グリッドで組むということ</span>
      </a></li>
    </ul>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --page-line: rgb(38 38 38 / 0.12);
  --plane: #395f77; /* 降りてくる色面。ページの地と明暗が離れるほど、文字の穴が読みやすい */
  --gutter: clamp(24px, 7vw, 96px);
}

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

/* 降りてくる層。中は「次のページの複製」と「ページ名を抜いた色面」。JS が付け外しする */
.veil { position: fixed; inset: 0; z-index: 100; overflow: hidden; clip-path: inset(0 0 100% 0); pointer-events: none; }
.veil > .page { position: absolute; inset: 0; width: 100%; min-height: 0; overflow: hidden; }
.plane { position: absolute; inset: 0; display: block; }
.plane__fill { fill: var(--plane); }
.plane text { font-size: 200px; font-weight: 800; letter-spacing: -0.02em; } /* 大きさは JS が画面の幅に合わせ直す */

.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: 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; }

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

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

.index__title { margin: 0 0 48px; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.posts { margin: 0; padding: 0; list-style: none; }

.posts__link {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px 48px;
  padding: 32px 0;
  border-top: 1px solid var(--page-line);
  color: inherit;
  text-decoration: none;
}

.posts__date { flex: none; width: 160px; font-size: 14px; letter-spacing: 0.04em; color: var(--page-mute); }
.posts__title { font-size: clamp(20px, 3vw, 24px); font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; word-break: auto-phrase; }
.posts__link:hover .posts__title { text-decoration: underline; text-underline-offset: 6px; }

/* 記事のページ(次のページ側)の組み */
.article { padding: 48px var(--gutter) 96px; }
.article__title { margin: 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.article__lead { margin: 24px 0 40px; max-width: 32em; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

.article__art {
  margin: 0;
  height: min(160px, 24vh);
  border-radius: 4px;
  background: linear-gradient(100deg, #efbc7c 0%, #e0a968 38%, #7abb78 72%, #4686b9 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 DROP = 0.495; // 色面が上から降りきるまでの時間(秒)
const HOLD = 0.18; // 覆いきってから、穴を広げ始めるまで(秒)
const ZOOM = 0.612; // 穴が広がって画面から抜けるまでの時間(秒)
const FILL = 0.86; // 読みごろのときの文字の幅(画面の幅に対する比)
const MARGIN = 1.3; // 縦画が画面を覆いきるための余裕
const SWITCH = 24; // この倍率から、字を縦画の四角に替える(1 万 px ほどの大きさの字は、ブラウザが描かなくなる)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
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");
  if (!doc.querySelector("[data-page]")) throw new Error("data-page が無い");
  return { next: doc.querySelector("[data-page]"), title: doc.title };
}

// 次のページの複製と、その上にかぶせる色面(行き先のページ名を mask で抜く)
function makeVeil(next) {
  const [w, h] = [innerWidth, innerHeight];
  const veil = document.body.appendChild(Object.assign(document.createElement("div"), { className: "veil", inert: true }));
  veil.innerHTML = `<svg class="plane" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
    <defs><mask id="word-hole" maskUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#fff" stroke="none"></rect>
    <rect class="plane__bar" width="0" height="${h}" stroke="none"></rect></mask></defs><rect class="plane__fill" width="${w}" height="${h}" mask="url(#word-hole)"></rect></svg>`;
  const copy = veil.insertAdjacentElement("afterbegin", next.cloneNode(true));
  copy.removeAttribute("data-page");
  copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); // 同じ id を 2 つ作らない
  const word = Object.assign(document.createElementNS("http://www.w3.org/2000/svg", "text"), { textContent: next.dataset.name });
  for (const [key, value] of [["text-anchor", "middle"], ["x", w / 2], ["y", h / 2], ["stroke", "none"]]) word.setAttribute(key, value);
  return { veil, word, w, h, bar: veil.querySelector(".plane__bar"), stem: Number(next.dataset.stem) };
}

// 字形を測って、読みごろの大きさ・くぐり抜ける縦画の中心と太さ・画面を覆いきる倍率を決める
function measure({ veil, word, stem, w, h }) {
  veil.querySelector(".plane").append(word); // mask の中の文字は測れないので、いったん描かれる場所に置いて測る
  const style = getComputedStyle(word), size = (parseFloat(style.fontSize) * FILL * w) / word.getComputedTextLength();
  word.style.fontSize = `${size}px`;
  const x0 = word.getExtentOfChar(stem).x; // くぐる字の左端(送り幅には字間が入るので、中点は使わない)
  // くぐる字を canvas に 1 字だけ描き、字の上下の中ほどの行で、字の中央を通る縦画の幅を測る(和文は字の幅と縦画の太さが違う)
  const g = Object.assign(document.createElement("canvas"), { width: size * 2, height: size * 2 }).getContext("2d", { willReadFrequently: true });
  Object.assign(g, { lang: "ja", font: `${style.fontWeight} ${size}px ${style.fontFamily}` });
  const ch = word.textContent[stem], m = g.measureText(ch), mid = (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
  g.fillText(ch, 0, size * 1.4);
  const row = g.getImageData(0, Math.round(size * 1.4 - mid), size * 2, 1).data, filled = (x) => row[x * 4 + 3] > 128;
  let a = Math.round(m.width / 2), b = a; // くぐる字は、中央を縦画が貫き、すぐ横にほかの画が無い字(I・T、和文なら工・小・山・川 など)にする
  while (filled(a - 1)) a--;
  while (filled(b + 1)) b++;
  word.setAttribute("y", h / 2 + mid); // くぐる字の上下の中ほどを画面の中央に置く
  veil.querySelector("mask").append(word);
  const ox = x0 + (a + b + 1) / 2, half = (b - a + 1) / 2;
  return { ox, oy: h / 2, half, max: (MARGIN * Math.max(ox, w - ox)) / half };
}

async function swap(url, push) {
  const { next, title } = await load(url);
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  const parts = makeVeil(next);
  await document.fonts.ready; // 代わりの書体で測ると、縦画から外れた所へ向かって穴が広がる
  const { ox, oy, half, max } = measure(parts);
  const draw = (s) => { // 大きくなったら、字を縦画と同じ位置・幅の四角に替える
    parts.word.setAttribute("visibility", s < SWITCH ? "visible" : "hidden");
    parts.word.setAttribute("transform", `translate(${ox} ${oy}) scale(${s}) translate(${-ox} ${-oy})`);
    Object.entries({ x: ox - half * s, width: s < SWITCH ? 0 : half * 2 * s }).forEach(([k, v]) => parts.bar.setAttribute(k, v));
  };
  const state = { drop: 0, zoom: 0 }, current = document.querySelector("[data-page]");
  await gsap.timeline()
    .timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、同じ流れのままほぼ一瞬で切り替える
    .to(state, { drop: 1, duration: DROP, ease: "power3.inOut", onUpdate: () => (parts.veil.style.clipPath = `inset(0 0 ${(1 - state.drop) * 100}% 0)`) })
    .add(() => { current.replaceWith(next); scrollTo(0, 0); }) // 差し替えは覆いきってから。穴は掛け算で広げる(足し算だと、近づくほど遅く見える)
    .to(state, { zoom: 1, duration: ZOOM, delay: HOLD, ease: "power1.in", onUpdate: () => draw(max ** state.zoom) });
  parts.veil.remove();
  document.title = title;
  const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
}

async function go(url, push) {
  if (busy) return;
  busy = true;
  try { await 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) 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();
  go(url.href, true);
});
// ブラウザの戻る・進むでも、同じ色面で移る(同じページの中の移動は除く)
addEventListener("popstate", () => location.pathname !== shownPath && go(location.href, false));

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

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