デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
リンクが押されたら次のページを読み込み、今のページの中身を、上にある行から順に少し上へずらしながら消していきます。見出しは行ごとに分けてあり、ラベル・見出しの行・画像・リード・ボタンが、上から波のように抜けていきます。抜けきったら同じ枠に次のページの中身を入れ、今度も上の行から順に、下からせり上げて見せます。ヘッダーとナビはそのまま残り、現在地の下線だけが次のリンクへ滑るので、幕や色面で画面を覆わずに、同じ枠の中身だけが入れ替わったと読めます。見出しの字をマスクの中で入れ替える遷移や、スクロールで行を出す見せ方と違い、ページの中身全体が行を単位にして、上へ抜けて下から入るひと続きの流れで入れ替わります。
抜けるのは 0.4 秒の power2.in で、上へ 24px ずらし、行ごとに 0.05 秒ずつ遅らせます。入るのは 0.8 秒の power3.out で、下 40px から上げ、行ごとに 0.06 秒ずつ遅らせます。抜ける側は加速して消え、入る側はすっと来て静かに止まるので、行が次々に押し上げられていくように見えます。入る行の濃さは 0.3 秒で先に出しきり、位置だけを長く動かしているので、薄い行が並んで読みにくい時間が短くなります。現在地の下線は 0.7 秒の power3.inOut で、次のリンクの位置と幅へ移ります。
行の順番は、HTML の順ではなく、画面での上端の位置(同じ高さなら左から)で決めます。右の列の画像は見出しと同じ高さにあるので、見出しと一緒に早く抜けます。狭い画面で画像が本文の下へ回ると、画像は最後に入ります。入れ替えたあとに並んだ位置で数え直すのがこつです。見出しは、文節の切れ目で行に分けて span に入れ、display: block を付けてください。inline のままの span には transform が効かず、行が動きません。
抜ける動きと次のページの読み込みは同時に始め、両方が終わってから中身を入れ替えます。読み込みが遅いときは、抜けきった空の枠のまま待ちます。URL は pushState で書き換え、ブラウザの戻る・進むでも同じ動きで入れ替えます。入れ替えたらスクロールを頭に戻し、読み上げとキーボードの位置を新しいページの見出しへ移します。読み込めなかったときは、ふつうのページ移動に任せます。
つまみは、ずらす量と遅らせる間隔です。間隔を 0.08 秒ほどに広げると 1 行ずつ語りかけるように、0.03 秒に詰めるとまとまって切り替わります。上へずらす量を 0 にすると、その場で消えて下から入る控えめな形になります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。コーポレートサイトや制作事例のように、ページの枠が共通で中身だけが変わるサイトの遷移に向いています。
コード
<!-- 遷移で入れ替えるのは data-page の中だけ。次のページも同じ形で書き、動かす行に data-line を付ける -->
<header class="site-header">
<a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="site-header__nav" aria-label="メイン">
<ul class="site-header__links" data-nav>
<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>
<span class="site-header__bar" data-bar aria-hidden="true"></span>
</nav>
</header>
<main class="page" data-page>
<div class="page__text">
<p class="page__label" data-line>Service</p>
<!-- 見出しは、文節の切れ目で行に分けて span に入れる。行ごとに抜けて、行ごとに入る -->
<h1 class="page__title"><span data-line>暮らしに寄り添う、</span><span data-line>小さな店のサイトを。</span></h1>
<p class="page__lead" data-line>ここに説明文が入ります。</p>
<a class="button" href="/service/price/" data-line>料金を見る</a>
</div>
<!-- 写真の代わりの丘と日。案件では img に差し替える(本屋さんのページは page__art--shelf) -->
<figure class="page__art page__art--hills" data-line></figure>
</main>
:root {
--page-bg: #f2f1ed;
--page-ink: #262626;
--page-mute: #6b6b6b;
--on-ink: #ffffff;
--sage: #6f9770; /* 丘の絵の色 */
--shelf: #475c6a; /* 本棚の絵の地(墨のスレート) */
--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); 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__nav { position: relative; } /* 現在地の下線は、この箱を基準に置く */
.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; transition: color 0.7s; }
.site-header__links a { display: inline-block; line-height: 1; } /* 下線の位置を、字の箱から決める */
.site-header .site-header__logo,
.site-header a[aria-current="page"] { color: var(--page-ink); }
.site-header a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
/* 現在地の下線。ナビは動かさず、この 1 本だけを次のリンクへ滑らせる(位置と幅は JS が入れる) */
.site-header__bar { position: absolute; left: 0; top: 0; width: 0; height: 2px; background: var(--page-ink); pointer-events: none; }
.page {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 432px);
align-items: start;
gap: 48px;
padding: 56px var(--gutter) 96px;
}
.page__label { margin: 0 0 16px; 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, 4.4vw, 56px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; }
.page__title:focus { outline: none; } /* 読み上げの位置を移すためだけの焦点なので、枠は出さない */
.page__lead { margin: 32px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.page__note { margin: 32px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--page-mute); }
/* 行ごとに動かすので、行の span は 1 つずつの箱にする(inline のままでは transform が効かない) */
.page__title > span { display: block; word-break: auto-phrase; } /* 狭い画面で行が折り返すときは、文節で折る */
.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: 2px solid var(--page-ink); outline-offset: 4px; }
.page__art { margin: 0; aspect-ratio: 9 / 8; border-radius: 4px; overflow: hidden; }
.page__art img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* 写真の代わりの絵。案件では img に差し替える */
.page__art--hills {
background:
radial-gradient(circle 44px at 72% 30%, #ffffff 0 96%, transparent 100%),
radial-gradient(80% 62% at 22% 108%, color-mix(in srgb, var(--sage) 62%, var(--page-bg)) 0 98%, transparent 100%),
radial-gradient(70% 52% at 88% 112%, color-mix(in srgb, var(--sage) 88%, var(--page-bg)) 0 98%, transparent 100%),
color-mix(in srgb, var(--sage) 24%, var(--page-bg));
}
.page__art--shelf {
--board: color-mix(in srgb, var(--shelf) 60%, #111111); /* 棚板 */
--spines: repeating-linear-gradient(90deg, #93b2c9 0 26px, transparent 0 32px, #d9ba92 0 62px, transparent 0 68px, #a2c49f 0 92px, transparent 0 98px, #d99590 0 124px, transparent 0 130px, #aea0cf 0 158px, transparent 0 164px);
background:
linear-gradient(transparent 43%, var(--board) 0 45%, transparent 0 87%, var(--board) 0 89%, transparent 0),
var(--spines) 40px 16.2% / calc(100% - 80px) 32% no-repeat,
var(--spines) 64px 81.4% / calc(100% - 104px) 30% no-repeat,
var(--shelf);
}
@media (max-width: 760px) {
.page { grid-template-columns: minmax(0, 1fr); gap: 40px; padding-top: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.site-header a { transition: none; }
}
import { gsap } from "gsap";
const OUT = { y: -24, duration: 0.4, ease: "power2.in", stagger: 0.05 }; // 今の行が上へ抜ける
const IN = { y: 40, duration: 0.8, ease: "power3.out", stagger: 0.06 }; // 次の行が下から入る
const FADE = { duration: 0.3, ease: "power2.out", stagger: 0.06 }; // 入る行の濃さは先に出しきる
const BAR = { duration: 0.7, ease: "power3.inOut" }; // 現在地の下線が滑る
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const nav = document.querySelector("[data-nav]");
const bar = document.querySelector("[data-bar]");
let busy = false;
let shownPath = location.pathname; // いま見せているページ
// 行を、上端の位置(同じ高さなら左から)の順に並べる。この順で抜け、この順で入る
const lines = (page) =>
[...page.querySelectorAll("[data-line]")]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left)
.map(({ el }) => el);
const speed = (tween) => tween.timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で
// 新しい URL に合うリンクへ aria-current を付け替える
function setCurrent() {
for (const link of nav.querySelectorAll("a")) {
if (link.pathname === location.pathname) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
}
// 現在地の下線を、aria-current のリンクの下へ置く。slide なら滑らせる(無ければ消す)
function mark(slide) {
const current = nav.querySelector("[aria-current]");
const to = current
? { x: current.offsetLeft, y: current.offsetTop + current.offsetHeight + 3, width: current.offsetWidth, opacity: 1 }
: { opacity: 0 };
gsap.to(bar, { ...to, ...BAR, duration: slide && !reduceMotion ? BAR.duration : 0, overwrite: true });
}
// 次のページの 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]");
if (!next) throw new Error("data-page が無い");
return { next, title: doc.title };
}
async function go(url, push) {
if (busy) return;
busy = true;
try {
const loading = load(url);
const current = document.querySelector("[data-page]");
if (push) history.pushState(null, "", url);
shownPath = location.pathname;
setCurrent();
mark(true);
// 上の行から順に抜く。読み込みが遅いときは、抜けきったまま待つ
const out = speed(gsap.to(lines(current), { ...OUT, opacity: 0 }));
const [{ next, title }] = await Promise.all([loading, out]);
gsap.set(next.querySelectorAll("[data-line]"), { opacity: 0 }); // 入る前の 1 コマで見えないように
current.replaceWith(next);
document.title = title;
scrollTo(0, 0);
// 行の順番は、入れ替えたあとに並んだ位置で決める(狭い画面では絵が見出しの下に来る)
const order = lines(next);
speed(gsap.to(order, { opacity: 1, ...FADE }));
await speed(gsap.from(order, { ...IN, clearProps: "transform" }));
const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
heading?.setAttribute("tabindex", "-1");
heading?.focus({ preventScroll: true });
} 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", () => {
if (location.pathname !== shownPath) go(location.href, false);
});
document.fonts.ready.then(() => mark(false));
addEventListener("resize", () => mark(false));