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

ページ遷移で今のページがカードの大きさに縮んで横へ送られ、次のカードが広がる

デモ

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

仕様

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

しくみと調整

リンクが押されたら次のページを読み込み、今のページの複製と次のページを、画面に固定した 2 枚のカードに入れます。まず今のページが画面の 0.6 倍のカードに縮み、角が丸まって影が落ちます。次に、となりで待っていた次のページのカードと一緒に列を 1 枚分横へ送り、次のカードが中央に来たら全画面に広げます。縮む → 送る → 広がるの 3 段なので、奥へ遠のいたり上に重なったりする遷移と違い、ページが横に並んだカードの 1 枚であることが見えます。

3 段はどれも power3.inOut で、縮むのに 0.55 秒、送るのに 0.75 秒、広がるのに 0.65 秒かけます。段と段は 0.1 秒ずつ重ねて、止まらずにつなぎます。カードのあいだは全画面のときの 80px をカードと一緒に縮めるので、0.6 倍のときは 48px です。広がる段では、前のカードを広がるカードと同じ速さで外へ押し出し、あいだの割合を保ったまま画面の外へ抜けさせます。

カードは scale で縮めるので、角の丸みと影も一緒に縮みます。カードのときに角 24px、影を 24px 下に見せたいなら、0.6 で割った 40px で書いてください。影を消すときは none にせず、同じ形のまま長さと濃さを 0 にすると、途中も補間されます。今のページは複製し、スクロールしていた分だけ上へずらしてからカードに載せます。複製には同じ id が 2 つできるので外し、送っているあいだは舞台に inert を付けます。

「前の作品へ」のリンクには data-dir に -1 を付けて、左からカードが来るようにします。ブラウザの「戻る」では、そのページへ来たときと逆の向きから前のカードが来るように、向きと深さを履歴の state に残しておきます。差し替えたあとは、題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。

つまみは、カードの大きさ、カードのあいだ、角の丸みと影、3 段それぞれの時間です。カードを小さくすると両隣のカードが大きくのぞき、カルーセルらしさが強まります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。制作実績やケーススタディ、商品のラインナップのように、同じ形のページを順にめくって見せるサイトに向いています。

コード

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="/work/" aria-current="page">実績</a></li>
        <li><a href="/about/">プロフィール</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="case">
    <p class="case__eyebrow">Case 02 / 04</p>
    <h1 class="case__title">しのぶ窯</h1>
    <p class="case__meta">ブランディング・Webサイト・2026年</p>
    <!-- 前後の作品へのリンク。前へは左から、次へは右からカードが送られてくる -->
    <nav class="case__pager" aria-label="作品の移動">
      <a class="button button--ghost" href="/work/shiomi-coffee/" data-dir="-1"><span aria-hidden="true">←</span> 前の作品へ</a>
      <a class="button" href="/work/kita-paper/">次の作品へ <span aria-hidden="true">→</span></a>
    </nav>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --backdrop: #d6d2c8; /* カードの後ろに見える地(机の上のような、ページより一段暗い色) */
  --gutter: clamp(24px, 7vw, 96px);
}

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

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

/* 送るあいだだけ置く舞台。画面に固定し、2 枚のカードを並べる(どれも JS が付け外しする) */
.carousel {
  position: fixed;
  inset: 0;
  z-index: 100;
  overflow: hidden;
  background: var(--backdrop);
}

/* 1 枚のカード = 1 ページ。全画面の大きさのまま、JS が scale で縮める */
.carousel__card {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--page-bg);
}

.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; white-space: nowrap; }
.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; }

.case { padding: 64px var(--gutter) 96px; }

.case__eyebrow {
  margin: 0 0 16px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--page-mute);
}

.case__title { margin: 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.case__meta { margin: 16px 0 40px; font-size: 14px; letter-spacing: 0.04em; color: var(--page-mute); }
.case__pager { display: flex; flex-wrap: wrap; gap: 16px; }

.button {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  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;
}

.button--ghost { background: none; color: var(--page-ink); box-shadow: inset 0 0 0 1px currentColor; }

.page a:focus-visible { outline: 2px solid var(--page-ink); outline-offset: 4px; }

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

JavaScript
import { gsap } from "gsap";

const CARD = 0.6; // カードになったときの大きさ(画面に対する比)
const GAP = 80; // カードどうしの間(全画面のときの px。カードと一緒に縮む)
// カードのときの角と影。scale で一緒に縮むので、見せたい値(角 24px・影 24px 下に 56px ぼかし)の 1 / CARD 倍で書く
const AS_CARD = { scale: CARD, borderRadius: 24 / CARD, boxShadow: `0 ${24 / CARD}px ${56 / CARD}px rgba(0, 0, 0, 0.3)` };
const AS_PAGE = { scale: 1, borderRadius: 0, boxShadow: "0 0px 0px rgba(0, 0, 0, 0)" }; // 影は同じ形で 0 にすると補間できる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let busy = false;
let shownPath = location.pathname; // いま見せているページ
let depth = history.state?.depth ?? 0; // 履歴の深さ。戻る・進むのどちらかを見分ける
let arrived = history.state?.dir ?? 1; // いまのページへ来たときの向き。戻るときは、その逆から前のカードが来る

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

// クラス名を付けた div に、中身を入れて返す
function box(className, ...children) {
  const el = Object.assign(document.createElement("div"), { className });
  el.append(...children);
  return el;
}

// 縮む → 列を送る → 広がる。dir は 1 で次のカードが右から、-1 で左から来る
async function slide(url, dir, push) {
  const { next, title } = await load(url);
  if (push) {
    history.pushState({ depth: ++depth, dir }, "", url);
    arrived = dir;
  }
  shownPath = new URL(url).pathname;
  const current = document.querySelector("[data-page]");
  // 今のページは複製して、見えている位置のままカードに載せる(本物は、送り終えるまで舞台の下に残す)
  const { left, top, width } = current.getBoundingClientRect();
  const copy = current.cloneNode(true);
  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" });
  const cards = [box("carousel__card", copy), box("carousel__card", next)];
  const stage = box("carousel", ...cards);
  stage.inert = true; // 送っているあいだは、カードの中のリンクに焦点や読み上げが入らないように
  document.body.append(stage);

  const full = stage.clientWidth + GAP; // 全画面のときの 1 枚分の送り
  gsap.set(cards[1], { ...AS_CARD, x: dir * full * CARD }); // 次のカードは、縮んだ列の 1 枚分となりで待つ
  await gsap
    .timeline({ defaults: { ease: "power3.inOut" } })
    .timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
    .fromTo(cards[0], AS_PAGE, { ...AS_CARD, duration: 0.55 })
    .to(cards, { x: `-=${dir * full * CARD}`, duration: 0.75 }, 0.45)
    // 広がるカードと同じ速さで、前のカードを外へ押し出す(間が同じ割合のまま広がる)
    .to(cards[1], { ...AS_PAGE, duration: 0.65 }, 1.15)
    .to(cards[0], { x: -dir * full, duration: 0.65 }, 1.15);

  current.replaceWith(next); // 次のページをカードから出して、ふつうのページに戻す
  stage.remove();
  document.title = title;
  scrollTo(0, 0);
  // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  const heading = next.querySelector("h1");
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
}

async function go(url, dir, push) {
  if (busy) return;
  busy = true;
  await slide(url, dir, push).catch(() => location.assign(url)); // 読み込めなかったら、ふつうに移る
  busy = false;
}

// 同じサイトのほかのページへのリンクだけ横取りする(別タブ・修飾キー付き・同じページ内のリンクは邪魔しない)。
// data-dir="-1" のリンク(前の作品へ)は、左からカードが来る
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, Number(link.dataset.dir ?? 1), true);
});

// ブラウザの戻るでは、来たときと逆の向きから前のページのカードが来る。進むは、そのページへ来たときと同じ向きから
addEventListener("popstate", (event) => {
  if (location.pathname === shownPath) return;
  const back = (event.state?.depth ?? 0) < depth;
  const dir = back ? -arrived : (event.state?.dir ?? 1);
  [depth, arrived] = [event.state?.depth ?? 0, event.state?.dir ?? 1];
  go(location.href, dir, false);
});

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

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