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

ページ遷移で今のページが蛇腹に折り畳まれ、次のページが広がって現れる

デモ

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

仕様

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

しくみと調整

リンクが押されたら、今のページの複製を縦 10 枚の帯に割り、隣どうしを交互の向きへ折って蛇腹に畳みます。帯の位置は折り角から計算し、隣り合う帯が 3D の同じ辺を共有するように置くので、折れても一枚の紙がつながったまま畳まれて見えます。畳みきったところでページを差し替え、次のページを同じ蛇腹の形から広げて開きます。縦の帯が降りて閉じる幕や、ページを面ごと回す遷移、タイルが 1 枚ずつ裏返る遷移と違い、ページそのものが紙のように折り畳まれるのが持ち味です。

折り角は 0° から 82° まで、0.95 秒の power2.inOut で動かします。82° まで折ると、畳んだ束の幅は画面の 14% ほどになります。帯は 10 枚で、偶数にすると束が画面の中央で左右対称に畳まれます。折れた面には墨色の陰を重ね、手前を向く面は 18%、奥を向く面は 50% を、折り角の sin に比例させています。面の中は一様にせず、折り目ぎわほど濃い勾配にしてあり、この濃淡が紙の折り目らしさを作ります。

帯をぴったり並べると、折れているあいだ継ぎ目に地が細く透けます。帯の幅を隣へ 1px だけ広げて重ねてください。重なりは次の帯が覆うので、平らなときの見た目は変わりません。遠近は帯ごとに付けず、帯を入れた層の perspective にまとめて、消失点を画面の中央に置きます。距離を画面の幅に合わせておくと、狭い画面でも同じ奥行きに見えます。

ページの差し替えは、畳みきってから行います。早いと、折り目のすき間から次のページが覗きます。畳んだ束の周りに見える地は、紙に近い明るさにしてあります。暗い地にすると、畳んだところで画面がほとんど地の色に沈み、折り目の明暗が読めなくなります。複製には同じ id が 10 個できるので外し、帯の層に inert を付けて、折っているあいだ焦点や読み上げが入らないようにしてください。差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。

つまみは、帯の枚数、折り角、畳む時間、陰の濃さ、遠近の距離です。枚数を増やすと細かい蛇腹に、4 枚ほどに減らすと屏風のようにゆっくり畳まれます。折り角は浅くすると畳みきらず、差し替えが見えてしまうので、80° 前後は保ってください。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。紙もの・出版・展示のように、素材そのものを見せたいサイトのページ遷移に向いています。

コード

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="/books/">作品</a></li>
        <li><a href="/studio/" aria-current="page">工房</a></li>
        <li><a href="/journal/">読みもの</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="hero">
    <div class="hero__copy">
      <p class="hero__eyebrow">Bookbinding & print</p>
      <h1 class="hero__title">手で折り、<br>手で綴じる。</h1>
      <p class="hero__lead">ここに説明文が入ります。</p>
      <a class="button" href="/books/">本を見る</a>
    </div>
    <p class="hero__art" role="img" aria-label="積み上げた紙の束"></p>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --desk: #e4e0d6; /* 畳まれた束の周りに見える地。紙に近い明るさにする */
  --depth: 94vw; /* 視点までの距離(幅 1280px で 1200px)。小さいほど折り目が強くすぼまる */
  --crease: 20 25 29; /* 折れた面の陰の色(墨のスレートを暗くした色) */
  --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 が付け外しする) */
.is-folding { background: var(--desk); }
.is-folding body { background: none; }
.is-folding [data-page] { visibility: hidden; }

/* 帯の入れ物。画面に固定し、中の帯を画面の中央を消失点にして折る */
.fold { position: fixed; inset: 0; z-index: 100; overflow: hidden; perspective: var(--depth); perspective-origin: 50% 50%; pointer-events: none; }

/* 1 枚の帯 = 今のページの複製を左へずらして、その帯の範囲だけ見せたもの。左の辺を軸に折る */
.fold__leaf { --fade: 0.3; position: absolute; top: 0; height: 100vh; overflow: hidden; transform-origin: 0 50%; }
.fold__leaf:nth-child(even) { --fade: 0.68; } /* 奥を向く面は中ほどまで濃い */

/* 折れた面の陰。折り目ぎわ(両端)ほど濃くすると、紙の折り目らしく見える */
.fold__leaf::after { content: ""; position: absolute; inset: 0; opacity: var(--shade, 0); background: linear-gradient(90deg, rgb(var(--crease)), rgb(var(--crease) / var(--fade)) 50%, rgb(var(--crease))); }

.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 {
  display: grid;
  gap: 48px;
  align-items: center;
  padding: 48px var(--gutter) 96px;
}

@media (min-width: 861px) {
  .hero { grid-template-columns: 1fr 384px; gap: 64px; }
}

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

/* 写真の代わりの画像。案件では img に差し替える */
.hero__art {
  margin: 0;
  height: min(480px, 60vh);
  border-radius: 4px;
  background:
    linear-gradient(#7f664b, #7f664b) 0 100% / 100% 72px no-repeat,
    linear-gradient(rgb(255 252 244 / 0.72), rgb(255 252 244 / 0.72)) 48px 64px / 96px 288px no-repeat,
    repeating-linear-gradient(180deg, #e8d9c5 0 28px, #d9ba92 28px 56px, #be9f77 56px 84px);
}

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

JavaScript
import { gsap } from "gsap";

const PANELS = 10; // 帯の枚数。偶数にすると、畳んだ束が画面の中央で左右対称になる
const ANGLE = 82; // 畳みきった折り角(°)。90° に近づくほど束が細くなる
const FOLD = 0.95; // 畳む/広げるのにかける時間(秒)
const SHADE = [0.18, 0.5]; // 折れた面の陰(手前向き・奥向き)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const box = (className) => Object.assign(document.createElement("div"), { className });
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 next = doc.querySelector("[data-page]");
  if (!next) throw new Error("data-page が無い");
  return { next, title: doc.title };
}

// ページの複製を帯の数だけ作る。i 枚目は複製を -i×帯幅 ずらして、i 番目の帯の範囲だけを見せる
function makeFold(page) {
  const { left, top, width } = page.getBoundingClientRect(); // スクロールしていても、見えている位置に置く
  const w = innerWidth / PANELS;
  const wrap = box("fold");
  const leaves = [...Array(PANELS)].map((_, i) => {
    const leaf = box("fold__leaf");
    Object.assign(leaf.style, { left: `${i * w}px`, width: `${w + 1}px` }); // 継ぎ目に地が透けないよう隣へ 1px 重ねる
    const copy = page.cloneNode(true);
    copy.removeAttribute("data-page");
    copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); // 同じ id を 2 つ作らない
    Object.assign(copy.style, { position: "absolute", left: `${left - i * w}px`, top: `${top}px`, width: `${width}px`, margin: "0" });
    leaf.append(copy);
    return leaf;
  });
  wrap.append(...leaves);
  wrap.inert = true; // 複製のリンクに焦点や読み上げが入らないように
  return { wrap, leaves };
}

// 角度 a から帯 1 枚ずつの位置を出す。隣どうしが 3D の同じ辺を共有するので、紙のようにつながって折れる
function bend(leaves, a) {
  const w = innerWidth / PANELS;
  const c = Math.cos((a * Math.PI) / 180), s = Math.sin((a * Math.PI) / 180);
  leaves.forEach((leaf, i) => {
    const dx = (1 - c) * (innerWidth / 2 - i * w); // 左右の端から中央へ詰まっていく量。折り目は奥と手前を交互に向く
    leaf.style.transform = `translate3d(${dx}px, 0, ${i % 2 ? -w * s : 0}px) rotateY(${i % 2 ? -a : a}deg)`;
    leaf.style.setProperty("--shade", SHADE[i % 2] * s);
  });
}

// 帯の層を画面に出し、折り角を from から to へ動かしてから外す
function run({ wrap, leaves }, from, to) {
  bend(leaves, from); // 出す前に始まりの角度を入れる(平らなまま付けると、複製が一瞬ちらつく)
  document.body.append(wrap);
  const fold = { a: from };
  const duration = reduceMotion ? 0.05 : FOLD; // 動きを減らす設定では、同じ流れのままほぼ一瞬で切り替える
  return gsap.to(fold, { a: to, duration, ease: "power2.inOut", onUpdate: () => bend(leaves, fold.a) }).then(() => wrap.remove());
}

async function swap(url, push) {
  const { next, title } = await load(url);
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  const current = document.querySelector("[data-page]");
  document.documentElement.classList.add("is-folding"); // 本物のページを隠し、地を机の色にする
  await run(makeFold(current), 0, ANGLE); // 今のページを畳む
  current.replaceWith(next); // 差し替えは畳みきってから。早いと、折り目のすき間から次のページが覗く
  scrollTo(0, 0);
  await run(makeFold(next), ANGLE, 0); // 次のページを同じ形から広げる
  document.documentElement.classList.remove("is-folding");
  document.title = title;
  const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
}

async function go(url, push) {
  if (busy) return;
  busy = true;
  try { await swap(url, push); } catch { location.href = url; } finally { 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);
});

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

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