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

ページ遷移で画面が左右の扉のように割れて開き、次のページが奥から現れる

デモ

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

仕様

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

しくみと調整

リンクが押されたら次のページを読み込み、今のページを 2 枚複製して、左半分と右半分だけを見せる扉にします。本物のページはその裏で次のページに差し替え、扉を外側の縁を軸に rotationY で手前へ回して開きます。画面を上下の幕で割る演出と違い、ページそのものが両開きの扉になって開くので、奥の部屋へ入っていくような切り替えになります。

扉は 1.1 秒の power1.in で 76° まで回します。重い扉のように動き出しはゆっくりで、70° を越えるころに画面の外へ抜けます。扉の面には内側の縁ほど濃い陰を重ね、回るほど濃くします。奥の次のページは、0.92 倍に縮めて墨色の幕をかけた所から、0.5 秒遅れの 1 秒で 1 倍へ戻し、幕も同じころに晴らします。幕は扉の隙間になる中央ほど薄くしてあり、開き始めに奥から光が差すように見えます。

遠近は扉ごとに付けず、2 枚の扉を入れた箱の perspective にまとめます。扉ごとに付けると、消失点がそれぞれの軸(画面の端)に来て、横から覗いたような開き方になります。箱にまとめると消失点が画面の中央にそろい、内側の縁が手前へ迫りながら大きくなって見えます。

複製したページは、そのままだとページの先頭が写るので、スクロールしていた分だけ上へずらしてから扉にします。複製には同じ id が 2 つずつできるので外し、inert を付けて焦点や読み上げが入らないようにしてください。次のページを縮める軸は、ページ全体の中央ではなく画面の中央に置きます。長いページでは、既定の軸のままだと画面の外へ向かって縮みます。動画や iframe は、複製すると読み込み直されます。

つまみは、開く角度と時間、陰の濃さ、奥のページの縮み方と幕の濃さ、遠近の距離です。距離を短くすると扉が大きく迫り、長くすると平たく開きます。読み込みを待ってから開くので、遅いページでは押してから動き出すまでに間が空きます。リンクに触れた時点で読み込み始めると、その間を縮められます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。建築やインテリア、ホテルのように「中へ入る」感じを出したいサイトに向いています。

コード

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="/projects/">作品</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">
    <p class="hero__eyebrow">Architecture & interiors</p>
    <h1 class="hero__title">光と風の通る、<br>家づくり。</h1>
    <p class="hero__lead">ここに説明文が入ります。</p>
    <a class="button" href="/projects/">作品を見る</a>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --backdrop: #1a2024; /* 縮んだ次のページの周りに見える奥の地 */
  --dark: 15 19 22; /* 扉の陰と幕の色(墨のスレートを暗くした色) */
  --depth: 125vw; /* 視点までの距離(幅 1280px で 1600px)。小さいほど扉が大きく迫る */
  --gutter: clamp(24px, 7vw, 96px);
}

body { margin: 0; background: var(--page-bg); }

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

/* 開くあいだは、縮んだページの周りを奥の地の色にする */
.is-opening { background: var(--backdrop); }
.is-opening body { background: none; }

/* 扉の入れ物。画面に固定し、中の扉を画面の中央を消失点にして回す(どれも JS が付け外しする) */
.doors {
  position: fixed;
  inset: 0;
  z-index: 100;
  overflow: hidden;
  perspective: var(--depth);
  pointer-events: none;
}

/* 1 枚の扉 = 今のページの複製。左の扉は左半分、右の扉は右半分だけを見せ、外側の縁を軸に回す */
.door { position: absolute; inset: 0; overflow: hidden; }
.door--left { clip-path: inset(0 50% 0 0); transform-origin: 0 50%; }
.door--right { clip-path: inset(0 0 0 50%); transform-origin: 100% 50%; }

/* 扉の面の陰。手前へ迫る内側の縁ほど濃くする */
.door__shade { position: absolute; top: 0; bottom: 0; width: 50%; opacity: 0; }
.door--left .door__shade { left: 0; background: linear-gradient(90deg, rgb(var(--dark) / 0.3), rgb(var(--dark) / 0.9)); }
.door--right .door__shade { right: 0; background: linear-gradient(270deg, rgb(var(--dark) / 0.3), rgb(var(--dark) / 0.9)); }

/* 次のページにかける墨色の幕。扉の隙間(中央)ほど薄くして、奥から光が差すように見せる */
.doors-veil {
  position: fixed;
  inset: 0;
  z-index: 99;
  background: linear-gradient(90deg, rgb(var(--dark)), rgb(var(--dark) / 0.55), rgb(var(--dark)));
  pointer-events: none;
}

.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 { padding: 96px var(--gutter); }

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

.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 OPEN = 1.1; // 扉が開ききるまでの時間(秒)
const ANGLE = 76; // 開ききる角度。70° を越えると、扉の内側の縁が画面の外へ抜ける
const DEPTH = 0.92; // 次のページが奥から出てくるときの大きさ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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 };
}

const box = (className) => Object.assign(document.createElement("div"), { className });

// 今のページを 2 枚複製して左右の扉にする。見えている位置のまま、左半分と右半分だけを見せる
function makeDoors(page) {
  const { left, top, width } = page.getBoundingClientRect(); // スクロールしていても、見えている位置に置く
  const doors = box("doors");
  const leaves = ["left", "right"].map((side) => {
    const leaf = box(`door door--${side}`);
    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}px`, top: `${top}px`, width: `${width}px`, margin: "0" });
    leaf.append(copy, box("door__shade"));
    return leaf;
  });
  doors.append(...leaves);
  doors.inert = true; // 複製のリンクに焦点や読み上げが入らないように
  return { doors, leaves };
}

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]");
  const { doors, leaves } = makeDoors(current);
  const veil = box("doors-veil");
  document.body.append(veil, doors);
  document.documentElement.classList.add("is-opening");
  // 本物のページは、扉の奥で次のページに差し替える
  current.replaceWith(next);
  scrollTo(0, 0);
  gsap.set(next, { transformOrigin: `50% ${innerHeight / 2}px` }); // 画面の中央を軸に縮める
  await gsap
    .timeline({ defaults: { duration: OPEN, ease: "power1.in" } })
    .timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
    .to(leaves[0], { rotationY: -ANGLE }, 0) // 左の扉は左の縁を軸に、右の扉は右の縁を軸に、手前へ開く
    .to(leaves[1], { rotationY: ANGLE }, 0)
    .fromTo(doors.querySelectorAll(".door__shade"), { opacity: 0 }, { opacity: 0.8 }, 0) // 回るほど扉の面を陰にする
    .fromTo(next, { scale: DEPTH }, { scale: 1, duration: 1, ease: "power2.inOut" }, 0.5)
    .fromTo(veil, { opacity: 0.7 }, { opacity: 0, duration: 0.95, ease: "power1.inOut" }, 0.35);
  [doors, veil].forEach((el) => el.remove());
  gsap.set(next, { clearProps: "transform,transformOrigin" });
  document.documentElement.classList.remove("is-opening");
  document.title = title;
  // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  const heading = next.querySelector("h1");
  heading?.setAttribute("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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。