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

ページ遷移でページの角が紙のようにめくれて、下から次のページが現れる

デモ

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

仕様

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

しくみと調整

「次の記事」を押すと、今のページの右下の角が紙のようにめくれ、めくれた裏面と折り目の陰を付けたまま、対角線に沿って左上へ抜けていきます。めくれた下には、もう次のページが敷いてあります。前の記事へ戻るときは同じ道を逆にたどり、紙が左上から戻ってきて重なります。ページを縦の帯に割って蛇腹に畳む遷移や、立方体の面として回す遷移と違い、ページ全体が 1 枚の紙のまま折り返されるので、冊子をめくる感覚に近い見え方になります。

折り目は、右下の角から左上の角へ向かう対角線に垂直な直線です。右下の角から対角線に沿って測った折り目までの距離を、0 から対角線の長さまで 1.4 秒の power2.inOut で動かし、折り目より角の側の紙を、折り目で鏡に映した形を裏面にします。表に残る部分は今のページの写しを clip-path の polygon で切り、裏面と陰は SVG の多角形で描きます。どれも画面の四角を折り目の直線で切るだけなので、角の数は 3〜5 つで済みます。

裏面は生成りにグレージュを混ぜた少し暗い紙の色にし、折り目ぎわを一段暗くしたグラデーションを、折り目から裏面の先へ向けて塗ります。グラデーションは userSpaceOnUse にして、画面の座標で向きを決めてください。既定の objectBoundingBox のままだと、多角形の形が変わるたびに向きと長さがずれます。折り目のそばの次のページには幅 72px の陰を落とし、めくれ始めと抜けぎわは薄くします。めくれた紙の縁が表の紙に落とす陰は、8px 先の折り目で映した同じ形を、裏面の下に敷くだけで作れます。

進むときは、下のページを先に次のページへ差し替えて頭へスクロールし、今のページの写しをめくります。写しはスクロールしていた分だけ上へずらして置くので、ページの途中で押しても、見えていた画面のままめくれます。戻る向きかどうかは、リンクの rel の prev か、履歴に残した深さで見分けます。写しには同じ id ができるので外し、紙の層は inert にして、焦点や読み上げが入らないようにします。差し替えたあとは、題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。

つまみは、めくれる時間と ease、裏面の色、陰の濃さと幅です。ease を power3.in にすると、ゆっくり持ち上がってから一気にめくれます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。連載の読みものや冊子のカタログ、紙ものを扱うお店のように、ページを順にめくって読ませたいサイトに向いています。

コード

HTML
<!-- 遷移で差し替えるのは 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="/works/">作品</a></li>
        <li><a href="/about/">事務所について</a></li>
        <li><a href="/journal/" aria-current="page">読みもの</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="issue">
    <div class="issue__copy">
      <p class="issue__eyebrow">Journal</p>
      <p class="issue__no">連載 第3回</p>
      <h1 class="issue__title">紙を選ぶところから<br>始める</h1>
      <p class="issue__lead">ここに説明文が入ります。</p>
    </div>
    <p class="issue__art" role="img" aria-label="日の差す丘"></p>
    <nav class="issue__pager" aria-label="前後の記事">
      <a class="issue__link" href="/journal/binding-tools/" rel="prev"><span>← 前の記事</span>製本の道具をそろえる</a>
      <a class="issue__link issue__link--next" href="/journal/paper-grain/" rel="next"><span>次の記事 →</span>紙の目と、折り目の話</a>
    </nav>
  </main>
</div>

<!-- めくれる紙の層。今のページの写しを入れる sheet と、裏面と陰を描く SVG(色は CSS)。写しのリンクに焦点が入らないよう inert にする -->
<div class="peel" hidden inert>
  <div class="peel__sheet"></div>
  <svg class="peel__fx" aria-hidden="true">
    <defs>
      <linearGradient id="peel-crease" gradientUnits="userSpaceOnUse"><stop offset="0" class="peel__shade"></stop><stop offset="1" class="peel__shade" stop-opacity="0"></stop></linearGradient>
      <linearGradient id="peel-back" gradientUnits="userSpaceOnUse"><stop offset="0" class="peel__back-fold"></stop><stop offset="0.16" class="peel__back"></stop><stop offset="1" class="peel__back-tip"></stop></linearGradient>
    </defs>
    <polygon class="peel__crease" fill="url(#peel-crease)"></polygon>
    <polygon class="peel__cast"></polygon>
    <polygon class="peel__flap" fill="url(#peel-back)"></polygon>
  </svg>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --line: rgb(38 38 38 / 0.12);
  --paper-back: #e5dfd3; /* めくれた紙の裏面。生成りにグレージュを混ぜた、少し暗い紙の色 */
  --paper-fold: #d1c8b7; /* 裏面の折り目ぎわ */
  --paper-tip: #dad1c1; /* 裏面の先 */
  --crease: rgb(0 0 0 / 0.26); /* 折り目のそばで、次のページに落ちる陰 */
  --cast: rgb(0 0 0 / 0.12); /* めくれた紙の縁が、表の紙に落とす陰 */
  --gutter: clamp(24px, 7vw, 96px);
}

body { margin: 0; background: var(--page-bg); }
.page { min-height: 100vh; background: var(--page-bg); color: var(--page-ink); }

/* めくれる紙の層。画面に固定し、めくっているあいだ下のページのクリックは止める */
.peel { position: fixed; inset: 0; z-index: 100; overflow: clip; }
.peel[hidden] { display: none; }
.peel__sheet { position: absolute; inset: 0; overflow: clip; }
.peel__fx { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.peel__shade { stop-color: var(--crease); }
.peel__back-fold { stop-color: var(--paper-fold); }
.peel__back { stop-color: var(--paper-back); }
.peel__back-tip { stop-color: var(--paper-tip); }
.peel__cast { fill: var(--cast); }

.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; }
.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; }

.issue { display: grid; gap: 48px; padding: 48px var(--gutter) 48px; }
@media (min-width: 861px) {
  .issue { grid-template-columns: 1fr 480px; gap: 40px 64px; }
}
.issue__eyebrow { margin: 0 0 12px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--page-mute); }
.issue__no { margin: 0 0 16px; font-size: 14px; letter-spacing: 0.04em; color: var(--page-mute); }
.issue__title { margin: 0; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.3; letter-spacing: -0.02em; }
.issue__lead { max-width: 25em; margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

/* 写真の代わりの丘と日。案件では img に差し替える */
.issue__art {
  --deep: #a98860; --mid: #d9ba92; --pale: #ebdfcb;
  margin: 0;
  aspect-ratio: 480 / 424;
  border-radius: 4px;
  background:
    radial-gradient(circle 56px at 70% 30%, var(--pale) 0 96%, transparent 100%),
    radial-gradient(70% 46% at 22% 100%, var(--deep) 0 98%, transparent 100%),
    radial-gradient(64% 58% at 84% 104%, var(--mid) 0 98%, transparent 100%),
    linear-gradient(180deg, var(--mid), var(--pale));
}

.issue__pager { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 24px; padding-top: 16px; box-shadow: inset 0 1px 0 var(--line); }
.issue__link { display: grid; gap: 8px; min-width: min(320px, 45%); padding: 8px 0; color: var(--page-ink); font-size: 18px; font-weight: 700; line-height: 1.2; letter-spacing: -0.01em; text-decoration: none; }
.issue__link span { font-size: 13px; font-weight: 400; letter-spacing: 0.04em; color: var(--page-mute); }
.issue__link--next { text-align: right; }
.page a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

/* 遷移のあとに見出しへ移したフォーカスは枠を出さない(読み上げの位置を移すためだけのもの) */
.page h1[tabindex="-1"]:focus { outline: none; }

JavaScript
import { gsap } from "gsap";

const PEEL = 1.4; // めくれて抜けきるまで(秒)
const SHADOW = 72, CAST = 8; // 折り目のそばの陰の幅と、めくれた紙の縁が表の紙に落とす陰の幅(px)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const layer = document.querySelector(".peel"), sheet = layer.querySelector(".peel__sheet");
const [crease, cast, flap] = ["crease", "cast", "flap"].map((name) => layer.querySelector(`.peel__${name}`));
const [creaseGrad, backGrad] = layer.querySelectorAll("linearGradient");
let busy = false, depth = history.state?.depth ?? 0; // 履歴の深さ。戻るときは紙を逆に戻す
history.scrollRestoration = "manual";

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");
  return { next: doc.querySelector("[data-page]").cloneNode(true), title: doc.title }; // data-page が無ければここで失敗し、ふつうに移る
}

// 多角形を半平面(value が 0 以上の側、または以下の側)で切る
function clip(points, value, below) {
  const out = [];
  points.forEach((a, i) => {
    const b = points[(i + 1) % points.length], va = value(a), vb = value(b);
    if (va >= 0 !== below) out.push(a);
    if (va >= 0 !== vb >= 0) out.push([a[0] + ((b[0] - a[0]) * va) / (va - vb), a[1] + ((b[1] - a[1]) * va) / (va - vb)]);
  });
  return out;
}

// 折り目の位置 d(右下の角から対角線に沿った距離)で描く。折り目より角の側の紙を、折り目で鏡に映すと裏面になる
function render(d) {
  const W = innerWidth, H = innerHeight, D = Math.hypot(W, H), ux = -W / D, uy = -H / D;
  const rect = [[0, 0], [W, 0], [W, H], [0, H]];
  const s = ([x, y]) => (x - W) * ux + (y - H) * uy;
  const mirror = (at) => ([x, y]) => [x + 2 * (at - s([x, y])) * ux, y + 2 * (at - s([x, y])) * uy];
  const pts = (list) => list.map(([x, y]) => `${x},${y}`).join(" "), point = (k) => [W + k * ux, H + k * uy];
  const w = Math.min(SHADOW, d);
  sheet.style.clipPath = `polygon(${clip(rect, (p) => s(p) - d, false).map(([x, y]) => `${x}px ${y}px`)})`;
  flap.setAttribute("points", pts(clip(rect, (p) => s(p) - d, true).map(mirror(d))));
  cast.setAttribute("points", pts(clip(rect, (p) => s(p) - d - CAST, true).map(mirror(d + CAST))));
  crease.setAttribute("points", pts(clip(clip(rect, (p) => s(p) - d, true), (p) => s(p) - d + w, false)));
  crease.style.opacity = Math.min(1, d / SHADOW, (D - d) / SHADOW); // めくれ始めと抜けぎわは薄く
  const gradient = (el, [x1, y1], [x2, y2]) => Object.entries({ x1, y1, x2, y2 }).forEach(([k, v]) => el.setAttribute(k, v));
  gradient(creaseGrad, point(d), point(d - w));
  gradient(backGrad, point(d), point(2 * d));
}

// 紙の層に page の写しを入れ、折り目を from から to まで動かす
function peel(page, from, to, top = page.getBoundingClientRect().top) {
  const copy = page.cloneNode(true);
  copy.removeAttribute("data-page");
  copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); // 同じ id を 2 つ作らない
  copy.style.marginTop = `${top}px`; // スクロールしていても、見えている位置に置く
  sheet.replaceChildren(copy);
  const D = Math.hypot(innerWidth, innerHeight), fold = { d: from * D };
  render(fold.d); // 出す前に始まりの形を入れる(一瞬、平らな写しが見えないように)
  layer.hidden = false;
  // 動きを減らす設定では、同じ流れのままほぼ一瞬で済ませる
  return gsap.to(fold, { d: to * D, duration: reduceMotion ? 0.05 : PEEL, ease: "power2.inOut", onUpdate: () => render(fold.d) }).then(() => { layer.hidden = true; sheet.replaceChildren(); });
}

// 進むときは今のページがめくれて下に次のページが現れ、戻るときは前のページが左上から戻って重なる
async function go(url, back) {
  if (busy) return;
  busy = true;
  try {
    const { next, title } = await load(url);
    const current = document.querySelector("[data-page]");
    if (back) await peel(next, 1, 0).then(() => current.replaceWith(next));
    else {
      const top = current.getBoundingClientRect().top;
      current.replaceWith(next); // 下のページを先に差し替えて頭へスクロールし、今のページの写しをめくる
      scrollTo(0, 0);
      await peel(current, 0, 1, top);
    }
    scrollTo(0, 0);
    document.title = title;
    const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
    if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
  } catch { location.href = url; } finally { busy = false; } // 読めなければふつうに移る
}

// 同じサイトのほかのページへのリンクだけ横取りする。rel="prev" のリンクは、戻る向きにめくる
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(); // めくっている最中に押されたリンクも、ふつうのページ移動にしない
  if (!busy) { history.pushState({ depth: ++depth }, "", url.href); go(url.href, link.rel === "prev"); }
});

addEventListener("popstate", () => {
  const to = history.state?.depth ?? 0;
  go(location.href, to < depth); // 履歴を戻ったなら、紙を戻す向き
  depth = to;
});

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

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