デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の前に全面の色面を固定し、左の外に隠しておきます。リンクが押されたら、色面を左から入れて画面を覆い、覆いきったところでページの中身を差し替えて、そのまま右へ抜きます。色面は xPercent を -100 → 0 → 100 と動かすだけなので、1 枚の面が画面を横切っていくように見えます。次のページは、色面が覆うあいだに fetch で読んでおきます。
色面が入るのも抜けるのも 0.75 秒で、イージングは power3.inOut です。出ていくページは、色面が来るあいだに見出しを行ごとのマスクの上へ 0.5 秒で逃がし、ほかの中身を薄くします。入るページは、色面がまだ覆っているうちから見出しを 0.9 秒の expo.out で行ごとにせり上げ、画像を 1.12 倍から等倍へ引きます。色面の左端が抜けたときには、左の見出しがほぼ出そろっています。
色面を左に隠す初期位置を、CSS の transform に書かないでください。GSAP がその値を px の位置として読み込み、xPercent と二重にかかるので、色面が画面に入ってきません。初期位置は JS で入れます。差し替えは、色面が覆いきり、次のページを読み終えてからにします。早いと、まだ覆っていない右側から次のページが覗きます。
差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。ブラウザの戻る・進むでも同じ色面で移るようにしておくと、行き来の手触りがそろいます。読み込んだページの中のスクリプトは動かないので、ページごとの初期化は差し替えたあとに呼び直してください。PJAX のライブラリを使うサイトなら、覆う動きを離れるときの処理に、抜ける動きを入るときの処理に渡せば同じ形になります。
つまみは、色面が横切る時間とイージング、色面の色です。色面は遷移そのものの色なので、明るいページと暗いページの中間の明るさの色にすると、どちらの上でも縁が読めます。yPercent を動かせば、上下に横切るワイプになります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。制作会社やポートフォリオのように、ブランドの色を遷移のたびに見せたいサイトに向いています。
コード
<!-- 遷移で差し替えるのは 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="/" aria-current="page">ホーム</a></li>
<li><a href="/work/">実績</a></li>
<li><a href="/about/">私たちについて</a></li>
<li><a href="/contact/">お問い合わせ</a></li>
</ul>
</nav>
</header>
<main class="hero">
<div class="hero__text">
<p class="hero__eyebrow" data-fade>Design studio</p>
<!-- 見出しは行ごとにマスクで包み、中の行をせり上げる -->
<h1 class="hero__title">
<span class="hero__mask"><span class="hero__line">人の心を動かす</span></span>
<span class="hero__mask"><span class="hero__line">デザインを。</span></span>
</h1>
<p class="hero__lead" data-fade>ここに説明文が入ります。</p>
<a class="button" href="/work/" data-fade>制作実績を見る</a>
</div>
<!-- 画像は枠の中で拡大・縮小する。写真でも動画でもよい -->
<figure class="hero__media">
<img class="hero__image" src="/img/studio.jpg" alt="" width="768" height="960">
</figure>
</main>
</div>
<!-- 色面。body の末尾に 1 つだけ置く -->
<div class="wipe" aria-hidden="true"></div>
:root {
--wipe: #b27771; /* 色面の色。明るいページと暗いページの中間の明るさにすると、どちらの上でも縁が読める */
--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; }
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 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; }
/* 左に見出しの列、右に画像。狭い画面では画像を下へ回す */
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 384px);
align-items: center;
gap: 48px;
padding: 48px var(--gutter) 96px;
}
@media (max-width: 760px) {
.hero { grid-template-columns: minmax(0, 1fr); }
}
.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; }
/* 行を包むマスク。下に余白を足して、g や y の下が切れないようにする */
.hero__mask { display: block; overflow: hidden; padding-bottom: 0.06em; }
.hero__line { display: block; }
.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;
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;
}
/* 画像の枠。中の画像だけを拡大・縮小するので、はみ出しは枠で切る */
.hero__media { margin: 0; aspect-ratio: 4 / 5; border-radius: 4px; overflow: hidden; }
.hero__image { display: block; width: 100%; height: 100%; object-fit: cover; }
.page a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
/* 遷移のあとに見出しへ移したフォーカスは、枠を出さない(読み上げの位置を移すためだけのもの) */
.page h1[tabindex="-1"]:focus { outline: none; }
/* 色面。JS が画面の左の外へ置いてから見せる */
.wipe {
position: fixed;
inset: 0;
z-index: 100;
background: var(--wipe);
visibility: hidden;
/* 色面を切っておかないと、抜けたあともページを押せないままになる */
pointer-events: none;
}
.wipe[data-active] { visibility: visible; }
import { gsap } from "gsap";
const MOVE = 0.75; // 色面が入る・抜ける時間(秒)
const EASE = "power3.inOut";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const speed = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const wipe = document.querySelector(".wipe");
let busy = false;
let shownPath = location.pathname; // いま見せているページ
history.scrollRestoration = "manual"; // スクロール位置は、差し替えたあとに自分で戻す
// 色面は画面の左の外に置いておく(CSS の transform に書くと、GSAP がそれを px として読み、ずれが残る)
gsap.set(wipe, { xPercent: -100 });
// 次のページの 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 };
}
// 覆う: 色面を左から入れながら、今のページの見出しを上へ逃がし、ほかの中身を薄くする
const cover = (page) =>
gsap
.timeline()
.timeScale(speed)
.fromTo(wipe, { xPercent: -100 }, { xPercent: 0, duration: MOVE, ease: EASE }, 0)
.to(page.querySelectorAll(".hero__line"), { yPercent: -105, duration: 0.5, ease: "power3.in", stagger: 0.05 }, 0)
.to(page.querySelectorAll("[data-fade]"), { opacity: 0, y: -12, duration: 0.4, ease: "power2.in", stagger: 0.04 }, 0)
.to(page.querySelectorAll(".hero__image"), { scale: 1.05, duration: MOVE, ease: "power2.in" }, 0);
// 抜ける: 色面を右へ抜きながら、新しいページを出す。見出しは色面の裏にいるうちから上げ始め、左端が見えたときには出そろえる
const uncover = (page) =>
gsap
.timeline()
.timeScale(speed)
.to(wipe, { xPercent: 100, duration: MOVE, ease: EASE }, 0.05)
.fromTo(page.querySelectorAll(".hero__image"), { scale: 1.12 }, { scale: 1, duration: 1.2, ease: "expo.out" }, 0)
.fromTo(page.querySelectorAll(".hero__line"), { yPercent: 105 }, { yPercent: 0, duration: 0.9, ease: "expo.out", stagger: 0.08 }, 0.1)
.fromTo(page.querySelectorAll("[data-fade]"), { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.6, ease: "power2.out", stagger: 0.06 }, 0.22);
async function swap(url, push) {
const current = document.querySelector("[data-page]");
wipe.dataset.active = "";
// 色面が覆うあいだに次のページを読む。差し替えるのは、覆いきって読み終えてから
const [{ next, title }] = await Promise.all([load(url), cover(current)]);
if (push) history.pushState({}, "", url);
shownPath = new URL(url).pathname;
current.replaceWith(next);
document.title = title;
scrollTo(0, 0);
// 読み上げとキーボードの位置を、新しいページの見出しへ移す
const heading = next.querySelector("h1");
heading?.setAttribute("tabindex", "-1");
heading?.focus({ preventScroll: true });
await uncover(next);
delete wipe.dataset.active;
}
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", () => {
if (location.pathname !== shownPath) go(location.href, false);
});