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

ページ遷移で今のページの中身が行ごとに上へ抜け、次のページの中身が下から順に入る

デモ

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

仕様

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

しくみと調整

リンクが押されたら次のページを読み込み、今のページの中身を、上にある行から順に少し上へずらしながら消していきます。見出しは行ごとに分けてあり、ラベル・見出しの行・画像・リード・ボタンが、上から波のように抜けていきます。抜けきったら同じ枠に次のページの中身を入れ、今度も上の行から順に、下からせり上げて見せます。ヘッダーとナビはそのまま残り、現在地の下線だけが次のリンクへ滑るので、幕や色面で画面を覆わずに、同じ枠の中身だけが入れ替わったと読めます。見出しの字をマスクの中で入れ替える遷移や、スクロールで行を出す見せ方と違い、ページの中身全体が行を単位にして、上へ抜けて下から入るひと続きの流れで入れ替わります。

抜けるのは 0.4 秒の power2.in で、上へ 24px ずらし、行ごとに 0.05 秒ずつ遅らせます。入るのは 0.8 秒の power3.out で、下 40px から上げ、行ごとに 0.06 秒ずつ遅らせます。抜ける側は加速して消え、入る側はすっと来て静かに止まるので、行が次々に押し上げられていくように見えます。入る行の濃さは 0.3 秒で先に出しきり、位置だけを長く動かしているので、薄い行が並んで読みにくい時間が短くなります。現在地の下線は 0.7 秒の power3.inOut で、次のリンクの位置と幅へ移ります。

行の順番は、HTML の順ではなく、画面での上端の位置(同じ高さなら左から)で決めます。右の列の画像は見出しと同じ高さにあるので、見出しと一緒に早く抜けます。狭い画面で画像が本文の下へ回ると、画像は最後に入ります。入れ替えたあとに並んだ位置で数え直すのがこつです。見出しは、文節の切れ目で行に分けて span に入れ、display: block を付けてください。inline のままの span には transform が効かず、行が動きません。

抜ける動きと次のページの読み込みは同時に始め、両方が終わってから中身を入れ替えます。読み込みが遅いときは、抜けきった空の枠のまま待ちます。URL は pushState で書き換え、ブラウザの戻る・進むでも同じ動きで入れ替えます。入れ替えたらスクロールを頭に戻し、読み上げとキーボードの位置を新しいページの見出しへ移します。読み込めなかったときは、ふつうのページ移動に任せます。

つまみは、ずらす量と遅らせる間隔です。間隔を 0.08 秒ほどに広げると 1 行ずつ語りかけるように、0.03 秒に詰めるとまとまって切り替わります。上へずらす量を 0 にすると、その場で消えて下から入る控えめな形になります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。コーポレートサイトや制作事例のように、ページの枠が共通で中身だけが変わるサイトの遷移に向いています。

コード

HTML
<!-- 遷移で入れ替えるのは data-page の中だけ。次のページも同じ形で書き、動かす行に data-line を付ける -->
<header class="site-header">
  <a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
  <nav class="site-header__nav" aria-label="メイン">
    <ul class="site-header__links" data-nav>
      <li><a href="/works/">作品</a></li>
      <li><a href="/service/" aria-current="page">サービス</a></li>
      <li><a href="/about/">事務所について</a></li>
      <li><a href="/contact/">お問い合わせ</a></li>
    </ul>
    <span class="site-header__bar" data-bar aria-hidden="true"></span>
  </nav>
</header>

<main class="page" data-page>
  <div class="page__text">
    <p class="page__label" data-line>Service</p>
    <!-- 見出しは、文節の切れ目で行に分けて span に入れる。行ごとに抜けて、行ごとに入る -->
    <h1 class="page__title"><span data-line>暮らしに寄り添う、</span><span data-line>小さな店のサイトを。</span></h1>
    <p class="page__lead" data-line>ここに説明文が入ります。</p>
    <a class="button" href="/service/price/" data-line>料金を見る</a>
  </div>
  <!-- 写真の代わりの丘と日。案件では img に差し替える(本屋さんのページは page__art--shelf) -->
  <figure class="page__art page__art--hills" data-line></figure>
</main>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --on-ink: #ffffff;
  --sage: #6f9770; /* 丘の絵の色 */
  --shelf: #475c6a; /* 本棚の絵の地(墨のスレート) */
  --gutter: clamp(24px, 7vw, 96px);
}

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

.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__nav { position: relative; } /* 現在地の下線は、この箱を基準に置く */
.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; transition: color 0.7s; }
.site-header__links a { display: inline-block; line-height: 1; } /* 下線の位置を、字の箱から決める */
.site-header .site-header__logo,
.site-header a[aria-current="page"] { color: var(--page-ink); }
.site-header a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

/* 現在地の下線。ナビは動かさず、この 1 本だけを次のリンクへ滑らせる(位置と幅は JS が入れる) */
.site-header__bar { position: absolute; left: 0; top: 0; width: 0; height: 2px; background: var(--page-ink); pointer-events: none; }

.page {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 432px);
  align-items: start;
  gap: 48px;
  padding: 56px var(--gutter) 96px;
}

.page__label { margin: 0 0 16px; font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--page-mute); }
.page__title { margin: 0; font-size: clamp(36px, 4.4vw, 56px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; }
.page__title:focus { outline: none; } /* 読み上げの位置を移すためだけの焦点なので、枠は出さない */
.page__lead { margin: 32px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.page__note { margin: 32px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--page-mute); }

/* 行ごとに動かすので、行の span は 1 つずつの箱にする(inline のままでは transform が効かない) */
.page__title > span { display: block; word-break: auto-phrase; } /* 狭い画面で行が折り返すときは、文節で折る */

.button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--page-ink);
  color: var(--on-ink);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}
.button:focus-visible { outline: 2px solid var(--page-ink); outline-offset: 4px; }

.page__art { margin: 0; aspect-ratio: 9 / 8; border-radius: 4px; overflow: hidden; }
.page__art img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* 写真の代わりの絵。案件では img に差し替える */
.page__art--hills {
  background:
    radial-gradient(circle 44px at 72% 30%, #ffffff 0 96%, transparent 100%),
    radial-gradient(80% 62% at 22% 108%, color-mix(in srgb, var(--sage) 62%, var(--page-bg)) 0 98%, transparent 100%),
    radial-gradient(70% 52% at 88% 112%, color-mix(in srgb, var(--sage) 88%, var(--page-bg)) 0 98%, transparent 100%),
    color-mix(in srgb, var(--sage) 24%, var(--page-bg));
}
.page__art--shelf {
  --board: color-mix(in srgb, var(--shelf) 60%, #111111); /* 棚板 */
  --spines: repeating-linear-gradient(90deg, #93b2c9 0 26px, transparent 0 32px, #d9ba92 0 62px, transparent 0 68px, #a2c49f 0 92px, transparent 0 98px, #d99590 0 124px, transparent 0 130px, #aea0cf 0 158px, transparent 0 164px);
  background:
    linear-gradient(transparent 43%, var(--board) 0 45%, transparent 0 87%, var(--board) 0 89%, transparent 0),
    var(--spines) 40px 16.2% / calc(100% - 80px) 32% no-repeat,
    var(--spines) 64px 81.4% / calc(100% - 104px) 30% no-repeat,
    var(--shelf);
}

@media (max-width: 760px) {
  .page { grid-template-columns: minmax(0, 1fr); gap: 40px; padding-top: 32px; }
}

@media (prefers-reduced-motion: reduce) {
  .site-header a { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const OUT = { y: -24, duration: 0.4, ease: "power2.in", stagger: 0.05 }; // 今の行が上へ抜ける
const IN = { y: 40, duration: 0.8, ease: "power3.out", stagger: 0.06 }; // 次の行が下から入る
const FADE = { duration: 0.3, ease: "power2.out", stagger: 0.06 }; // 入る行の濃さは先に出しきる
const BAR = { duration: 0.7, ease: "power3.inOut" }; // 現在地の下線が滑る
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const nav = document.querySelector("[data-nav]");
const bar = document.querySelector("[data-bar]");
let busy = false;
let shownPath = location.pathname; // いま見せているページ

// 行を、上端の位置(同じ高さなら左から)の順に並べる。この順で抜け、この順で入る
const lines = (page) =>
  [...page.querySelectorAll("[data-line]")]
    .map((el) => ({ el, r: el.getBoundingClientRect() }))
    .sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left)
    .map(({ el }) => el);
const speed = (tween) => tween.timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で

// 新しい URL に合うリンクへ aria-current を付け替える
function setCurrent() {
  for (const link of nav.querySelectorAll("a")) {
    if (link.pathname === location.pathname) link.setAttribute("aria-current", "page");
    else link.removeAttribute("aria-current");
  }
}

// 現在地の下線を、aria-current のリンクの下へ置く。slide なら滑らせる(無ければ消す)
function mark(slide) {
  const current = nav.querySelector("[aria-current]");
  const to = current
    ? { x: current.offsetLeft, y: current.offsetTop + current.offsetHeight + 3, width: current.offsetWidth, opacity: 1 }
    : { opacity: 0 };
  gsap.to(bar, { ...to, ...BAR, duration: slide && !reduceMotion ? BAR.duration : 0, overwrite: true });
}

// 次のページの 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 };
}

async function go(url, push) {
  if (busy) return;
  busy = true;
  try {
    const loading = load(url);
    const current = document.querySelector("[data-page]");
    if (push) history.pushState(null, "", url);
    shownPath = location.pathname;
    setCurrent();
    mark(true);
    // 上の行から順に抜く。読み込みが遅いときは、抜けきったまま待つ
    const out = speed(gsap.to(lines(current), { ...OUT, opacity: 0 }));
    const [{ next, title }] = await Promise.all([loading, out]);
    gsap.set(next.querySelectorAll("[data-line]"), { opacity: 0 }); // 入る前の 1 コマで見えないように
    current.replaceWith(next);
    document.title = title;
    scrollTo(0, 0);
    // 行の順番は、入れ替えたあとに並んだ位置で決める(狭い画面では絵が見出しの下に来る)
    const order = lines(next);
    speed(gsap.to(order, { opacity: 1, ...FADE }));
    await speed(gsap.from(order, { ...IN, clearProps: "transform" }));
    const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
    heading?.setAttribute("tabindex", "-1");
    heading?.focus({ preventScroll: true });
  } 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);
});

document.fonts.ready.then(() => mark(false));
addEventListener("resize", () => mark(false));

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

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