本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

ページ遷移

ページ遷移で、縁が曲線になったカーテンが下から上がって画面を覆い、上へ抜ける

デモ

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

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

縁が弧を描いたカーテンが画面の下からせり上がって今のページを覆い、次のページの名前を見せてから、上へ抜けていきます。上がるときにふくらんでいた縁は、抜けるときにはへこんで見えます。

カーテンは SVG の path で描き、上がるときは上端、抜けるときは下端の形を、進み具合に合わせて毎回組み立て直しています。形は左右の端の高さと中央のふくらみを3次ベジェ曲線で結んだもので、抜けるときは下端の中央が先に上がるため、弧の向きが逆になります。ふくらみの大きさは画面の高さの24%で、進み具合に応じた sin の値で増減させ、始まりと終わりでちょうど0になるようにしました。覆いきった瞬間のカーテンは、必ず平らです。

カーテンは淡い色と濃い色を重ねてあり、濃いほうが0.07秒遅れて追いかけます。弧の縁に見える淡いラインの太さは、このずれの時間で決まります。次のページ名は濃いほうのカーテンと同じ形で切り抜いて載せているので、文字だけが縁からはみ出すことはありません。上がるのも抜けるのも power3.inOut で、覆いきったところで0.18秒だけ止め、ページ名を読ませてから抜けます。

ページの移動は、濃いカーテンが覆いきってから location.href で行います。次のページは覆われた状態から始まり、そこからカーテンが上へ抜けていく作りで、ページ名は sessionStorage で受け渡しています。カーテンの pointer-events は切っておいてください。画面全体を覆う要素なので、切り忘れると、カーテンが抜けたあとも下のリンクやボタンが押せないままになります。

ページの中にカーテンと同じ色の飾りがあると、縁からのぞいたときにカーテンのこぶのように見えるので、飾りの色は少しずらしておきます。ページごとにカーテンの色を変えれば、章が切り替わったことまで伝えられます。

コード

HTML
<nav class="site-nav" aria-label="メイン">
  <a class="site-nav__logo" href="/" data-page-name="ホーム">Hi! I'm HIGASHIYUKI®</a>
  <ul class="site-nav__links">
    <li><a href="/work/">制作実績</a></li>
    <li><a href="/about/">スタジオ</a></li>
    <li><a href="/journal/">読みもの</a></li>
    <li><a href="/recruit/">採用情報</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;
  flex-wrap: wrap; /* 狭い画面では、リンクをロゴの下の行へ送る */
  align-items: center;
  gap: 16px 32px;
  padding: 32px clamp(24px, 7vw, 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;
  white-space: nowrap;
}

.site-nav__links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 32px;
  margin: 0;
  padding: 0;
  list-style: none;
  white-space: nowrap;
}

.site-nav a { color: inherit; text-decoration: none; }
.site-nav a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

.curtain {
  --lead: #877fa1;       /* 先に上がる淡いカーテン */
  --main: #57506e;       /* 追いかける濃いカーテン。白いページ名が読める暗さにする */
  --on-curtain: #ffffff; /* カーテンに載せる文字 */
  --name-size: clamp(64px, 17vw, 220px); /* ページ名が 4 字なら、どの幅でも 1 行に収まる */
  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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。