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

ページ遷移で縦帯が順に閉じて開くと次のページになっている

デモ

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

仕様

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

しくみと調整

画面の前に縦の帯を並べた幕を固定しておき、リンクが押されたら、帯を左から順に上から降ろして画面を閉じます。閉じきったところでページの中身を差し替え、同じ順に帯を下へ抜いて開くと、次のページになっています。帯は yPercent を -100 → 0 → 100 と動かすだけなので、閉じるのも開くのも上から下への同じ流れに見えます。次のページは、帯が閉じるあいだに fetch で読んでおきます。

帯は 6 本で、1 本が動くのは 0.495 秒、隣の帯とは 0.063 秒ずつずらし、イージングは power2.inOut です。最初の帯が動き出してから最後の帯が止まるまでの 0.81 秒が、閉じきるまでの時間になります。帯は左から開くので、新しいページの見出しは開き始めて 0.18 秒後から、行ごとのマスクの下から 0.81 秒の expo.out でせり上げ、左の見出しがいちばん先に目に入るようにしています。帯は 2 色を交互に塗り、閉じきった画面でも帯の並びが読めるようにしています。

帯をぴったり並べると、継ぎ目にページが細く透けます。帯の幅を隣へ 1px はみ出させて重ねてください。帯を上に隠しておく初期位置を、CSS の transform に書くのも避けます。GSAP がその値を px の位置として読み込み、yPercent と二重にかかるので、帯が降りてきません。初期位置は JS で入れます。差し替えは、最後の帯が閉じきってからにします。早いと、まだ閉じていない右端から次のページが覗きます。

差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。ブラウザの戻る・進むでも同じ帯で移るようにしておくと、行き来の手触りがそろいます。読み込んだページの中のスクリプトは動かないので、ページごとの初期化は、差し替えたあとに呼び直してください。PJAX のライブラリを使うサイトなら、閉じる動きを離れるときの処理に、開く動きを入るときの処理に渡せば、同じ形になります。

つまみは、帯の本数、隣とのずれ、1 本が動く時間、帯の色です。ずれを大きくすると階段のように、0 にすると 1 枚の幕のように動きます。stagger の from を center にすると中央から、end にすると右から閉じます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。イベントやギャラリー、ポートフォリオのように、ページ全体を大きく切り替えたいサイトの遷移に向いています。

コード

HTML
<!-- 遷移で差し替えるのは data-page の中だけ。次のページも同じ形で書き、地の色はページのクラスで変える -->
<div class="page page--night" 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="/events/" aria-current="page">公演</a></li>
        <li><a href="/lineup/">出演者</a></li>
        <li><a href="/venue/">会場</a></li>
        <li><a href="/tickets/">チケット</a></li>
      </ul>
    </nav>
  </header>

  <main class="hero">
    <p class="hero__eyebrow" data-fade>Live — Spring 2027</p>
    <!-- 見出しは行ごとにマスクで包み、中の行をせり上げる -->
    <h1 class="hero__title">
      <span class="hero__mask"><span class="hero__line">夜更けに聴く</span></span>
      <span class="hero__mask"><span class="hero__line">春のライブ</span></span>
    </h1>
    <p class="hero__lead" data-fade>ここに説明文が入ります。</p>
    <a class="button" href="/tickets/" data-fade>チケットを買う</a>
  </main>
</div>

<!-- 帯の幕。body の末尾に 1 つだけ置く。帯の本数は span の数で決まる -->
<div class="columns" aria-hidden="true">
  <span></span><span></span><span></span><span></span><span></span><span></span>
</div>

CSS
:root {
  --strip-odd: #688297; /* 帯の色。1 本おきに明るさを少し変えて、閉じた画面でも継ぎ目を読ませる */
  --strip-even: #5e768a;
  --gutter: clamp(24px, 7vw, 96px);
}

/* ページの色はページごとに持つ。帯の色は、明るいページにも暗いページにも縁が読める中間の明るさにする */
.page {
  min-height: 100vh;
  background: var(--page-bg);
  color: var(--page-ink);
}

.page--night { --page-bg: #111111; --page-ink: #eeece6; --page-mute: #a8a6a0; --button-bg: #eeece6; --button-ink: #111111; }
.page--paper { --page-bg: #f2f1ed; --page-ink: #262626; --page-mute: #6b6b6b; --button-bg: #262626; --button-ink: #ffffff; }

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

.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__mask { display: block; overflow: hidden; padding-top: 0.06em; }
.hero__line { display: block; }
.hero__lead { margin: 24px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

.button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--button-bg);
  color: var(--button-ink);
  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; }

/* 帯の幕。帯の並びは grid に任せ、JS が帯を画面の上へ隠してから見せる */
.columns {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  overflow: hidden;
  visibility: hidden;
  /* 幕を切っておかないと、開いたあともページを押せないままになる */
  pointer-events: none;
}

.columns[data-active] { visibility: visible; }

/* 隣の帯へ 1px はみ出させる。ぴったり並べると、継ぎ目にページが細く透ける */
.columns > span { width: calc(100% + 1px); background: var(--strip-odd); }
.columns > span:nth-child(even) { background: var(--strip-even); }

JavaScript
import { gsap } from "gsap";

const MOVE = 0.495; // 1 本の帯が閉じる・開く時間(秒)
const EACH = 0.063; // 隣の帯とのずれ(秒)。左の帯から順に動く
const EASE = "power2.inOut";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const speed = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const columns = document.querySelector(".columns");
const strips = columns.querySelectorAll("span");
let busy = false;
let shownPath = location.pathname; // いま見せているページ

history.scrollRestoration = "manual"; // スクロール位置は、差し替えたあとに自分で戻す
// 帯は画面の上に隠しておく(CSS の transform に書くと、GSAP がそれを px として読み、ずれが残る)
gsap.set(strips, { yPercent: -100 });

// 次のページの 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 close = (page) =>
  gsap
    .timeline()
    .timeScale(speed)
    .fromTo(strips, { yPercent: -100 }, { yPercent: 0, duration: MOVE, ease: EASE, stagger: EACH }, 0)
    .to(page.querySelectorAll(".hero__line"), { yPercent: -105, duration: 0.45, ease: "power3.in", stagger: 0.045 }, 0)
    .to(page.querySelectorAll("[data-fade]"), { opacity: 0, y: -12, duration: 0.36, ease: "power2.in", stagger: 0.036 }, 0);

// 開く: 帯を下へ抜きながら、新しいページの中身を出す。帯は左から開くので、左の見出しはすぐに出し始める
const open = (page) =>
  gsap
    .timeline()
    .timeScale(speed)
    .fromTo(strips, { yPercent: 0 }, { yPercent: 100, duration: MOVE, ease: EASE, stagger: EACH }, 0.045)
    .fromTo(page.querySelectorAll(".hero__line"), { yPercent: 105 }, { yPercent: 0, duration: 0.81, ease: "expo.out", stagger: 0.072 }, 0.225)
    .fromTo(page.querySelectorAll("[data-fade]"), { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.54, ease: "power2.out", stagger: 0.054 }, 0.333);

async function swap(url, push) {
  const current = document.querySelector("[data-page]");
  columns.dataset.active = "";
  // 帯が閉じるあいだに次のページを読む。差し替えるのは、閉じきって読み終えてから
  const [{ next, title }] = await Promise.all([load(url), close(current)]);
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  current.replaceWith(next);
  document.title = title;
  scrollTo(0, 0);
  // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  const heading = next.querySelector("h1");
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
  await open(next);
  delete columns.dataset.active;
}

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。