デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
幕は SVG の path 1 枚で、上端の形を進み具合から組み立てます。左右の端の高さと、中央のふくらみを 3 次ベジェで描くので、上がるときは上へふくらむ弧、覆いきると平ら、抜けるときは下端の中央が先に上がるへこむ弧になります。幕は淡い色と濃い色の 2 枚を少しずらして重ね、次のページ名を濃いほうの形で切り抜いて載せています。
上がるのも抜けるのも 0.85 秒、イージングは power3.inOut です。ふくらみは画面の高さの 24% で、sin を掛けて始まりと終わりでちょうど 0 になるようにしているので、覆いきった瞬間は必ず平らな面になります。2 枚の幕は 0.07 秒ずらしてあり、このずれが弧の縁に見える淡い帯の太さになります。覆いきってから 0.18 秒だけ止め、次のページ名を読ませてから抜けます。
幕は画面全体をふさぐので、pointer-events を切っておかないと、抜けたあともページを押せないままになります。ページの差し替えは、濃い幕が覆いきった瞬間に行ってください。少しでも早いと、弧の縁から次のページが覗きます。幕の縁から覗く飾りが幕と同じ色だと、幕のこぶに見えてしまうので、色をずらしておきます。
つまみは、上がる時間と抜ける時間、ふくらみの大きさ、肩の丸さを決める制御点の位置、2 枚の幕のずれ、そしてページ名が幕に付いていく割合です。ふくらみを 0 にすればまっすぐな幕になり、大きくすると勢いのある波になります。ページごとに幕の色を変えれば、章が変わったことも伝えられます。エージェンシーやコーポレートサイトの、ページ全体を切り替える遷移に向いています。
コード
<nav class="site-nav" aria-label="メイン">
<a class="site-nav__logo" href="/" data-page-name="Home">Hi! I'm HIGASHIYUKI®</a>
<ul class="site-nav__links">
<li><a href="/work/">Work</a></li>
<li><a href="/about/">About</a></li>
<li><a href="/journal/">Journal</a></li>
<li><a href="/contact/">Contact</a></li>
</ul>
</nav>
<!-- 幕は body の末尾に 1 つだけ置く。viewBox を 100 x 100 にして preserveAspectRatio を none に
すると、どんな画面の比でも画面いっぱいに伸びる -->
<div class="curtain" aria-hidden="true">
<svg class="curtain__svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<!-- ページ名を濃い幕の形で切り抜く。0〜1 で持つので、幕とは別に同じ形を書き込む -->
<clipPath id="curtain-clip" clipPathUnits="objectBoundingBox">
<path class="curtain__clip" d="M0 1H1V1H0Z" />
</clipPath>
</defs>
<path class="curtain__lead" d="M0 100H100V100H0Z" />
<path class="curtain__main" d="M0 100H100V100H0Z" />
</svg>
<!-- ページ名は幕と一緒に伸びると歪むので、SVG の上に HTML で重ねる -->
<div class="curtain__mask">
<p class="curtain__name">
<span class="curtain__label">NEXT PAGE</span>
<span class="curtain__word"></span>
</p>
</div>
</div>
.site-nav {
display: flex;
align-items: center;
gap: 32px;
padding: 32px 96px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
}
.site-nav__logo {
margin-right: auto;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.12em;
}
.site-nav__links {
display: flex;
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
.site-nav a { color: inherit; text-decoration: none; }
.site-nav a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
.curtain {
--lead: #8f7bff; /* 先に上がる淡い幕 */
--main: #5b3df5; /* 追いかける濃い幕 */
--on-curtain: #ffffff; /* 幕に載せる文字 */
--name-size: clamp(64px, 17vw, 220px);
position: fixed;
inset: 0;
z-index: 100;
visibility: hidden;
/* 幕を切っておかないと、抜けたあともページを押せないままになる */
pointer-events: none;
}
.curtain[data-active] { visibility: visible; }
.curtain__svg { display: block; width: 100%; height: 100%; }
.curtain__lead { fill: var(--lead); }
.curtain__main { fill: var(--main); }
/* 切り抜きは画面に固定し、中のページ名だけを動かす(この箱を動かすと切り抜きも一緒に動く) */
.curtain__mask {
position: absolute;
inset: 0;
clip-path: url(#curtain-clip);
}
.curtain__name {
display: grid;
align-content: center;
justify-items: center;
gap: 24px;
height: 100%;
margin: 0;
color: var(--on-curtain);
text-align: center;
}
.curtain__label {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
opacity: 0.86;
}
.curtain__word {
font-size: var(--name-size);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
import { gsap } from "gsap";
const RISE = 0.85; // 幕が上がって覆いきるまで(秒)
const LEAVE = 0.85; // 上へ抜けるまで(秒)
const LAG = 0.07; // 2 枚の幕のずれ。弧の縁に見える淡い帯の太さになる
const HOLD = 0.18; // 覆いきったまま止めて、ページ名を読ませる時間
const BULGE = 0.24; // 弧のふくらみ(画面の高さに対する比)
const CURVE = 0.22; // 制御点の位置(幅に対する比)。小さいほど肩が丸い
const FOLLOW = 0.65; // ページ名が幕に付いていく割合
const KEY = "curtain-next"; // 遷移で来たことを、次のページへ伝える印
const ease = gsap.parseEase("power3.inOut");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const curtain = document.querySelector(".curtain");
const lead = curtain.querySelector(".curtain__lead");
const main = curtain.querySelector(".curtain__main");
const clip = curtain.querySelector(".curtain__clip");
const name = curtain.querySelector(".curtain__name");
const word = curtain.querySelector(".curtain__word");
const state = { lead: 0, main: 0, up: true };
// 覆うときは上端が上へふくらむ弧、抜けるときは下端の中央が先に上がるへこむ弧。
// ふくらみは始まりと終わりで 0 になるので、覆いきると必ず平らな面になる
function shape(p, w, h, up) {
const e = ease(p);
const side = h * (1 - e); // 左右の端の高さ
const bulge = BULGE * h * Math.sin(Math.PI * e);
const c = side - (4 / 3) * bulge; // 3 次ベジェの制御点。曲線の頂点が side - bulge になる
return up
? `M0 ${h}V${side}C${w * CURVE} ${c} ${w * (1 - CURVE)} ${c} ${w} ${side}V${h}Z`
: `M0 0H${w}V${side}C${w * (1 - CURVE)} ${c} ${w * CURVE} ${c} 0 ${side}Z`;
}
function draw() {
main.setAttribute("d", shape(state.main, 100, 100, state.up));
lead.setAttribute("d", shape(state.lead, 100, 100, state.up));
clip.setAttribute("d", shape(state.main, 1, 1, state.up)); // 切り抜きは 0〜1 で書く
const offset = state.up ? 100 * (1 - ease(state.main)) : -100 * ease(state.main);
name.style.transform = `translateY(${offset * FOLLOW}%)`;
}
// 上がる(up)と抜ける(up でない)は同じ式。先に動くほうを 0 秒、追うほうを LAG 秒に置く
function run(up) {
Object.assign(state, { lead: 0, main: 0, up });
curtain.dataset.active = "";
const [first, second] = up ? ["lead", "main"] : ["main", "lead"];
const duration = up ? RISE : LEAVE;
return gsap
.timeline({ onUpdate: draw })
.timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
.to(state, { [first]: 1, duration, ease: "none" }, 0)
.to(state, { [second]: 1, duration, ease: "none" }, LAG);
}
const reveal = () => run(false).then(() => curtain.removeAttribute("data-active"));
// 遷移で来たときは、幕が下りたところから始めて上げる(直接開いたときは幕を出さない)
const arrived = sessionStorage.getItem(KEY);
if (arrived !== null) {
sessionStorage.removeItem(KEY);
word.textContent = arrived;
Object.assign(state, { lead: 1, main: 1, up: true });
curtain.dataset.active = "";
draw();
gsap.delayedCall(reduceMotion ? 0 : HOLD, reveal);
}
// 戻るボタンで帰ると、覆ったままの幕が復元されることがある。それも上げる
addEventListener("pageshow", (event) => {
if (event.persisted) reveal();
});
// 同じサイトの中のリンクだけ横取りする(別タブ・修飾キー付きのクリックは邪魔しない)
document.querySelectorAll('a[href^="/"]').forEach((link) => {
link.addEventListener("click", (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || link.target) return;
event.preventDefault();
word.textContent = link.dataset.pageName ?? link.textContent.trim();
sessionStorage.setItem(KEY, word.textContent);
// 覆いきってから移る。新しいページは、幕が下りた状態で読み込まれる
run(true).then(() => {
location.href = link.href;
});
});
});