本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ページ遷移

ページ遷移で曲線の幕が下から上がって画面を覆い、上へ抜ける

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 にすればまっすぐな幕になり、大きくすると勢いのある波になります。ページごとに幕の色を変えれば、章が変わったことも伝えられます。エージェンシーやコーポレートサイトの、ページ全体を切り替える遷移に向いています。

コード

HTML
<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>
CSS
.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;
}
JavaScript
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;
    });
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。