デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- 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 を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。
つまみは、降りる時間と広げる時間、覆いきってから待つ間、文字の幅、色面の色、そしてページ名とくぐる字の番号です。縦画の太さは描いて測るので、書体を替えても測り直しは要りません。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。読み物・雑誌・ブランドのサイトのように、行き先の名前そのものを見せて切り替えたいページ遷移に向いています。
コード
<!-- 遷移で差し替えるのは 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>
: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; }
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));