デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
リンクが押されたら、画面の上からローズの幕を降ろして今のページを覆い、幕の中央でロゴの字の輪郭を左から線で描いていきます。描けた字から順に塗りに変わり、ロゴが出そろうと幕が上がって、下から次のページが少し持ち上がりながら現れます。次のページは幕が降りているあいだに読み込むので、ロゴを描く時間がそのまま読み込みの待ち時間になります。色面で拭うだけの遷移と違い、ページが切り替わるたびにブランドのロゴを見せられます。
幕は 0.65 秒の power3.inOut で降り、覆われていく今のページは 48px 下へずれます。輪郭は降り始めて 0.5 秒後から 1 字 0.5 秒で描き、字ごとに 0.028 秒ずつずらして左から描き進めます。塗りは描き始めて 0.42 秒後から 0.3 秒で入れ、同時に輪郭の線を消します。線を残したままだと、字が線の太さの半分だけ太って見えます。幕は、読み込みとロゴの描き終わりの両方を待ってから 0.65 秒で上がります。
ロゴは、字ごとの path で書いた SVG を幕の中央に置きます。各 path に pathLength を 1 と付けておくと、字の長さに関係なく、破線のずれを 1 から 0 へ動かすだけで同じ時間で描けます。穴のある字(A や ® など)は外側と内側の輪郭を 1 本の path にまとめ、evenodd で塗ると穴が埋まりません。1 本に輪郭が複数あるときは、それぞれの輪郭の頭から同時に描き始まり、短い輪郭ほど先に描き終わります。
SVG の text に破線をかけて字を描こうとすると、Chromium では字の大きさによって破線の長さの測り方が変わり、字ごとの描き始めと描き終わりがそろいません。文字のままではなく、アウトライン化した path のロゴを使ってください。線の太さを保つ vector-effect も、pathLength と合わずに描く前の点が残るので使わず、狭い画面では線を太くします。幕の色は、墨色のロゴと 4:1 近いコントラストがとれる、落ち着いたローズにしています。ブランドの色に替えるときは、ロゴの色とのコントラストを確かめてください。
つまみは、幕の色、幕が降りる・上がる時間、字ごとのずらし方と塗りに変わる時間、次のページが持ち上がる量です。差し替えたあとは、題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。ファッションや香り、レストランのように、ロゴを印象づけたいブランドのサイトに向いています。
コード
<!-- 遷移で差し替えるのは 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="/shop/">ショップ</a></li>
<li><a href="/story/">ブランドについて</a></li>
<li><a href="/journal/">読みもの</a></li>
<li><a href="/contact/">お問い合わせ</a></li>
</ul>
</nav>
</header>
<main class="hero">
<p class="hero__eyebrow">Home fragrance</p>
<h1 class="hero__title">静かな部屋に、<br>木の香りを。</h1>
<p class="hero__lead">ここに説明文が入ります。</p>
<a class="button" href="/shop/">香りを選ぶ</a>
</main>
</div>
<!-- 幕。body の末尾に 1 つだけ置く。ロゴは、自分のロゴの SVG の path に差し替える(1 字 1 本。穴のある字は輪郭をまとめて 1 本に) -->
<div class="curtain" aria-hidden="true">
<svg class="curtain__logo" viewBox="0 0 778 51">
<path pathLength="1" d="M2.4 4.5l-0.1 42.4l10.4 0l0.2-17.7l16 0l0.2 0.2l0.1 17.5l10.2 0.1l0-42.6l-10 0l-0.3 0.2l0 15.5l-0.2 0.4l-16 0l-0.3-16.1z"/>
<path pathLength="1" d="M59.2 2.1l-2.5 0.9l-1.5 1.4l-0.9 2l0 2.2l0.9 2l1.7 1.6l2 0.7l2 0l2-0.7l1.8-1.6l0.8-2l0-2.2l-0.8-2l-1.5-1.4l-1.5-0.7zM55.2 17l-0.3 0.1l0.1 29.8l9.7 0.1l0.2-0.4l0-29.5z"/>
<path pathLength="1" d="M81.6 4.5l-0.1 28.1l10.1 0.2l0.2-28.2l-0.2-0.2zM86.6 36.1l-2.2 0.4l-2 1.4l-1.4 2.2l-0.2 2.3l1 2.7l1.6 1.6l2 0.8l2.5 0l2-0.8l1.7-1.6l0.9-2l0-2.5l-0.8-2l-1.3-1.3l-1.5-0.8z"/>
<path pathLength="1" d="M131.2 4.5l-0.1 42.4l10.3 0.1l0.2-42.4l-0.2-0.2z"/>
<path pathLength="1" d="M158.2 4.6l0 16l7.8 0.2l0.1-16.2l-0.1-0.2z"/>
<path pathLength="1" d="M198.8 16.3l-2.5 0.8l-1.7 1l-2.5 2.7l-0.3-3.8l-9.6 0.1l0.1 29.9l10-0.1l0-16l0.2-2.3l0.5-1.5l1.4-1.7l1.4-0.8l1.3-0.2l1.2 0.1l1.5 0.9l0.9 1.2l0.5 1.5l0.1 18.8l10 0l0-16l0.2-2.3l0.6-1.5l1.1-1.5l1.1-0.8l1.8-0.4l1.5 0.2l1.2 0.7l0.8 1.1l0.6 1.5l0.2 3l0 16l10 0l-0.2-20.8l-1.1-4l-2-3l-1.5-1.3l-2.8-1.3l-2-0.3l-3.2 0l-2.3 0.6l-1.7 0.8l-2 1.5l-1.8 1.9l-2.2-2.8l-2.5-1.4l-2.5-0.6z"/>
<path pathLength="1" d="M267.7 4.5l-0.1 42.4l10.4 0l0.2-17.7l16 0l0.3 0.2l0 17.5l10.2 0.1l0-42.6l-10 0l-0.2 0.2l0 15.5l-0.3 0.4l-16 0l-0.3-16.1z"/>
<path pathLength="1" d="M320.7 4.5l0 42.4l10.3 0.1l0.1-42.4l-0.1-0.2z"/>
<path pathLength="1" d="M365.5 3.3l-5 1l-5.2 2.2l-3.8 2.9l-1.8 2l-2.1 3l-1.7 4.2l-0.7 3.5l-0.3 3.3l0.5 5.2l1.3 4.3l2.2 4l4.1 4.3l4 2.4l4.3 1.6l4.5 0.8l6 0l6.2-1.1l5.3-1.7l1.5-0.8l0-0.3l0-22.3l-17.6 0.1l0.1 8.2l8 0.3l0 7.2l-1 0.6l-2 0.5l-3.5 0.3l-2.8-0.3l-2.5-0.7l-2-1.1l-2.2-1.8l-2.1-3.2l-1-3.8l0-5l0.7-2.7l1-2l2.4-2.8l1.2-1.1l3-1.5l2.3-0.5l3 0l4.2 1.1l1.8 0.9l2.2 1.9l6.4-7.3l0.1-0.2l-1.2-1.1l-1.8-1.2l-5.5-2.3l-5.5-1z"/>
<path pathLength="1" d="M412.8 4.5l-17.7 42.4l11.2 0.1l3-7.9l0.5-0.4l15.8 0l3.5 8.3l11.5-0.1l-17.5-42.3l-0.3-0.2zM417.8 16.3l-0.3 0.3l-4.9 13.6l10.3-0.1z"/>
<path pathLength="1" d="M465.3 3.4l-2.3 0.1l-4.2 1l-4.3 2.3l-2.1 2.1l-1.2 1.7l-0.7 1.8l-0.6 2.5l0 3.5l0.5 2.5l1.4 2.5l1.5 1.7l3.5 2.3l10.2 3.9l1.9 1.6l0.5 1l0 2.2l-0.8 1.5l-1.8 1.4l-2 0.4l-2.3 0l-2.7-0.7l-2.8-1.5l-2.5-2.2l-6.8 6.9l2.3 2l3 1.8l4 1.5l4.3 0.8l4.5 0l2.2-0.3l3.3-0.9l3-1.6l1.9-1.6l1.7-2l1.5-3l0.5-2.5l0.1-2.2l-0.3-3.3l-0.8-2l-1.6-2.1l-1.8-1.7l-2-1.2l-11.2-4.4l-1.5-1.4l-0.5-1.7l0.2-1.2l1-1.6l1.8-1l2.7-0.4l1.8 0.2l2.2 0.7l1.8 1l2 1.7l6.5-6.9l-3-2.4l-3.8-1.7l-4-1z"/>
<path pathLength="1" d="M494 4.5l-0.1 42.4l10.4 0l0.2-17.7l16 0l0.2 0.2l0.1 17.5l10.2 0.1l0-42.6l-10 0l-0.3 0.2l0 15.5l-0.2 0.4l-16 0l-0.3-16.1z"/>
<path pathLength="1" d="M547 4.5l-0.1 42.4l10.3 0.1l0.2-42.4l-0.2-0.2z"/>
<path pathLength="1" d="M567.8 4.5l15.8 24.6l0 17.8l10.2 0.1l0.2-17.9l16.2-24.5l-0.2-0.2l-11.7 0l-9 15.4l-9-15.3z"/>
<path pathLength="1" d="M620.4 4.4l-0.3 0.2l0.1 28.5l1 4.5l2.1 3.8l2.3 2.5l3 2.1l3.8 1.4l3 0.6l5 0l3.7-0.5l3.5-1.3l2.8-1.7l2.3-2.1l1.8-2.5l0.9-2l1-3.5l0.3-3.5l-0.1-26.5l-10.2 0l-0.1 25.5l-0.4 3.2l-1 2.3l-1.5 1.8l-2.5 1.2l-2.5 0.3l-2.5-0.3l-2.5-1.2l-1.9-2.3l-0.7-2l-0.3-3l-0.1-25.5z"/>
<path pathLength="1" d="M672.1 4.5l-0.1 42.4l10.2 0l0.1-19.3l15.8 19.4l13.4-0.1l-19.2-22.8l18-19.5l-0.2-0.2l-12.8 0.1l-15 17l-0.2-17.1z"/>
<path pathLength="1" d="M722 4.5l-0.1 42.4l10.3 0.1l0.1-42.4l-0.1-0.2z"/>
<path pathLength="1" d="M760 3.8l-3.5 1l-2.3 1.2l-2 1.6l-1.7 2.1l-1.4 2.4l-0.7 2.3l-0.3 2.7l0.3 3l0.7 2.5l1.3 2.3l1.8 2.2l2 1.6l2.3 1.2l2.5 0.8l2.7 0.3l3-0.3l2.5-0.7l2.3-1.2l2-1.7l1.7-2l1.4-2.5l0.7-2.2l0.3-3l-0.2-2.8l-0.8-2.5l-1.2-2.2l-1.9-2.3l-1.8-1.5l-2.5-1.3l-2.2-0.8l-2-0.2zM760.2 6.1l-2 0.5l-2.5 1.2l-1.9 1.6l-1.3 1.5l-1.8 4l-0.3 2.2l0.2 2.5l0.5 1.8l1.4 2.5l1.2 1.5l1.8 1.5l2 1.1l2.2 0.7l4.3 0l2.2-0.7l2-1.1l1.8-1.5l1.2-1.5l1.8-4l0.3-2.5l-0.2-2.3l-1.5-3.7l-1.6-2l-1.5-1.3l-3.8-1.8l-1.7-0.3zM755.5 9l-0.2 0.1l0 15.8l5.2 0.2l0.1-5.5l0.4-0.1l0.6 0.1l2.6 5.5l6-0.2l-3.4-6l1.7-1.5l0.9-1.8l0-2.7l-1.2-1.9l-2.2-1.5l-2.3-0.4zM760.7 13.3l0 2.2l2 0l0.9-0.6l0.1-0.8l-0.5-0.5z"/>
</svg>
</div>
:root {
--page-bg: #f2f1ed;
--page-ink: #262626;
--page-mute: #6b6b6b;
--curtain: #cc6257; /* 幕の色。墨色のロゴとのコントラストは 3.9:1 */
--logo-width: min(78vw, 790px); /* 幕の中央のロゴの幅 */
--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 が動かす) */
.curtain {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: var(--curtain);
box-shadow: 0 24px 64px rgb(69 27 23 / 0.22);
visibility: hidden;
}
.curtain__logo { width: var(--logo-width); height: auto; overflow: visible; color: var(--page-ink); }
/* ロゴの字。輪郭の線を描いてから塗る。pathLength="1" なので、破線のずれを 1 → 0 と動かすと描ける(ずれと濃さは JS が style で書く) */
.curtain__logo path {
fill: currentColor;
fill-opacity: 0;
fill-rule: evenodd; /* 1 本にまとめた内側の輪郭(A や ® の穴)を塗らない */
stroke: currentColor;
stroke-width: 1.4; /* viewBox の単位。ロゴと一緒に拡大・縮小する */
stroke-linejoin: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
}
/* 狭い画面ではロゴが小さくなるので、線を太くしておく(vector-effect: non-scaling-stroke は pathLength と合わず、描く前の点が残る) */
@media (max-width: 600px) {
.curtain__logo path { stroke-width: 2.8; }
}
.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; white-space: nowrap; }
.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 { padding: 96px var(--gutter); }
.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; }
.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(--page-ink);
color: #ffffff;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.page a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
/* 遷移のあとに見出しへ移したフォーカスは、枠を出さない(読み上げの位置を移すためだけのもの) */
.page h1[tabindex="-1"]:focus { outline: none; }
import { gsap } from "gsap";
const EACH = 0.028; // 字ごとに描き始めをずらす時間(秒)。1 字は 0.5 秒で描く
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const curtain = document.querySelector(".curtain");
const letters = curtain.querySelectorAll(".curtain__logo path"); // ロゴの字(左から順に並べておく)
let busy = false;
let 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");
const page = doc.querySelector("[data-page]");
if (!page) throw new Error("data-page が無い");
return { page, title: doc.title };
}
async function swap(url, push) {
const current = document.querySelector("[data-page]");
// 幕を降ろしながら、ロゴの輪郭を左の字から線で描き、描けた字から塗りに変える。そのあいだに次のページを読む
const cover = gsap
.timeline()
.timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
.fromTo(curtain, { yPercent: -100, autoAlpha: 1 }, { yPercent: 0, duration: 0.65, ease: "power3.inOut" })
.to(current, { y: 48, duration: 0.65, ease: "power3.inOut" }, 0) // 覆われていくページは少し下へ
.fromTo(letters, { strokeDashoffset: 1, strokeOpacity: 1, fillOpacity: 0 }, { strokeDashoffset: 0, duration: 0.5, ease: "power1.inOut", stagger: EACH }, 0.5)
// 塗りに変わるほど線を消す(線を残すと、字が線の太さの半分だけ太って見える)
.to(letters, { fillOpacity: 1, strokeOpacity: 0, duration: 0.3, ease: "power1.inOut", stagger: EACH }, 0.92);
const [{ page, title }] = await Promise.all([load(url), cover]);
if (push) history.pushState({}, "", url);
shownPath = new URL(url).pathname;
current.replaceWith(page);
document.title = title;
scrollTo(0, 0);
// 読み上げとキーボードの位置を、新しいページの見出しへ移す
const heading = page.querySelector("h1");
heading?.setAttribute("tabindex", "-1");
heading?.focus({ preventScroll: true });
// 幕を上げ、次のページを下から少し持ち上げる
await gsap
.timeline()
.timeScale(reduceMotion ? 20 : 1)
.to(curtain, { yPercent: -100, duration: 0.65, ease: "power3.inOut" }, 0.18)
.fromTo(page, { y: 64 }, { y: 0, duration: 0.7, ease: "power3.out", clearProps: "transform" }, 0.23)
.set(curtain, { autoAlpha: 0 });
}
async function go(url, push) {
if (busy) return;
busy = true;
await swap(url, push).catch(() => location.assign(url)); // 読み込めなかったら、ふつうに移る
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);
});