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

ページ遷移で見出しが下へ抜け、次のページの見出しが上から入る

デモ

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

仕様

動作
DOM
種類
レシピ
使うもの
SplitText
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

リンクが押されたら次のページを読み込み、今のページと同じ位置に重ねて、中身だけを入れ替えます。今のページの大見出しは 1 字ずつ行のマスクの下へ抜け、次のページの見出しの字がマスクの上から入ってきます。小見出し・本文の行・リンクも同じ向きで入れ替わり、画像は枠の中で下へ送られて、次の画像が上から降りてきます。ヘッダーはそのまま残り、ナビの色と現在地の下線だけが移るので、レイアウトを保ったまま中身が差し替わって見えます。

見出しは SplitText で字に分け、今の字を 0.5 秒の power3.in で下へ抜き、0.28 秒遅れて次の字を 0.8 秒の power3.out で上から入れます。隣の字とは 0.022 秒ずつ、2 行目は 0.06 秒遅らせます。抜ける字は加速して消え、入る字はゆっくり止まるので、字が次々に押し出されていくように見えます。小見出しや本文の行も同じ ease で、字より少し短い時間で抜け、少し遅れて入ります。画像は 1 秒かけて前の画像を下へ送り、次の画像を上から降ろします。

字間を詰めた見出しを字ごとのマスクで包むと、隣の字に掛かる字の端が欠けます。マスクは行ごとに 1 つにしてください。字の要素は SplitText の既定の div のままにします。tag を span にすると字に inline-block が付かず、y を動かしても字が動きません。地の色は、2 色をクロスフェードすると途中が濁った灰色になるので、次のページの色の幕を clip-path で上から 0.8 秒で降ろし、降りきったら地に色を移します。新旧のページは grid の同じマスに重ねるので、ページと画像の枠には地を塗らないでおきます。

ナビの色は、幕の縁がナビを通り過ぎるころに 0.3 秒で移し、現在地の下線はリンクの位置と幅を測って滑らせます。差し替えたあとは、題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。抜けていくページには inert を付け、字に分けた見出しは入れ替え終わったら元の文字に戻します。本文は行ごとに入れ替えるので、文や文節の切れ目で行を分けて書きます。

つまみは、字が抜ける・入る時間と ease、隣の字とのずれ、次の字が入り始めるまでの間、幕が降りる時間です。ずれを大きくすると 1 字ずつ落ちていくように、0 にすると行ごとにまとまって動きます。大見出しは入れ替わる字そのものを見せるので、広い画面では 144px まで大きく組んでいます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。建築やプロダクト、ポートフォリオのように、同じ型のページどうしを行き来するサイトの遷移に向いています。

コード

HTML
<!-- ヘッダーと地の色の幕はページをまたいで残し、入れ替えるのは data-page の中だけ。次のページも同じ形で書き、色は data-theme で決める -->
<div class="site" data-theme="night">
  <header class="site-header" data-theme="night">
    <a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
    <nav class="site-header__nav" aria-label="メイン">
      <ul class="site-header__links">
        <li><a href="/studio/" aria-current="page">事務所</a></li>
        <li><a href="/projects/">設計事例</a></li>
        <li><a href="/process/">考え方</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
      <span class="site-header__bar" aria-hidden="true"></span>
    </nav>
  </header>

  <div class="pages">
    <!-- 入れ替える部品は、どれもマスク(.mask)で包む。見出しは行ごとに包み、字に分けて動かす -->
    <main class="page" data-page data-theme="night">
      <div class="page__text">
        <p class="mask"><span class="page__eyebrow" data-swap>01 — Studio</span></p>
        <h1 class="page__title">
          <span class="mask"><span data-chars>静けさの</span></span>
          <span class="mask"><span data-chars>ある部屋</span></span>
        </h1>
        <p class="page__lead">
          <span class="mask"><span data-swap>ここに説明文が入ります。</span></span>
        </p>
        <p class="mask"><a class="page__more" href="/process/" data-swap><span>私たちの考え方</span> <span aria-hidden="true">→</span></a></p>
      </div>
      <figure class="page__art mask"><img src="/img/studio-arches.jpg" alt="アーチ窓の並ぶ、明るい漆喰の室内" width="852" height="1000" data-art></figure>
    </main>
  </div>

  <div class="wipe" aria-hidden="true"></div>
</div>

CSS
/* 2 つのテーマ。ヘッダー・ページ・地の色の幕が、それぞれ自分の data-theme の色を使う */
[data-theme="night"] { --bg: #111111; --ink: #eeece6; --mute: #a8a6a0; }
[data-theme="paper"] { --bg: #f2f1ed; --ink: #262626; --mute: #6b6b6b; }

:root { --gutter: clamp(24px, 7vw, 96px); }

body { margin: 0; }

/* 地の色は .site が持つ。幕は .site の地の上・中身の下に重ねる(isolation で重なりの箱を作る) */
.site {
  position: relative;
  isolation: isolate;
  min-height: 100vh;
  background: var(--bg);
}

/* 次のページの色の幕。JS が上から降ろし、降りきったら .site の地に色を移して上へ戻す */
.wipe {
  position: fixed;
  inset: 0;
  z-index: -1;
  background: var(--bg);
  clip-path: inset(0% 0% 100% 0%);
  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;
  line-height: 1;
  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: 16px 32px; margin: 0; padding: 0; list-style: none; white-space: nowrap; }

/* ナビの色は、テーマや現在地が替わると 0.3 秒で移る */
.site-header a { color: var(--mute); text-decoration: none; transition: color 0.3s; }
.site-header .site-header__logo,
.site-header a[aria-current="page"] { color: var(--ink); }

/* 現在地の下線。JS が今いるページのリンクの下へ動かす */
.site-header__bar { position: absolute; top: 0; left: 0; height: 2px; background: var(--ink); visibility: hidden; transition: background-color 0.3s; }

/* どのページも同じマスに置き、入れ替えのあいだ新旧を重ねる。ページは地を塗らない */
.pages { display: grid; }

.page {
  grid-area: 1 / 1;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 426px);
  gap: 48px;
  padding: 40px var(--gutter) 96px;
  color: var(--ink);
}

@media (max-width: 800px) {
  .page { grid-template-columns: minmax(0, 1fr); }
}

/* 入れ替える部品を包むマスク。枠の外へ出た部品を隠す */
.mask { display: block; margin: 0; overflow: clip; }
.mask:has(:focus-visible) { overflow: visible; }
.page [data-swap],
.page [data-chars] { display: block; width: fit-content; }

.page__eyebrow { font-size: 13px; font-weight: 700; line-height: 1.4; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }

/* 入れ替わる字そのものを見せるので、見出しは大きく組む。字間を詰めているので、マスクは字ごとでなく行ごとにする */
.page__title { margin: 16px 0 32px -0.05em; font-size: clamp(56px, 11.25vw, 144px); font-weight: 800; line-height: 0.93; letter-spacing: -0.02em; }
/* 和文の字面は行の箱より上へはみ出すので、マスクの上にも余白を足し、同じだけ上へ寄せる */
.page__title .mask { margin-top: -0.125em; padding: 0.125em 0 0.04em; }

/* 本文は行ごとに入れ替えるので、文や文節の切れ目で行を分けて書く(狭い画面で折り返すときも文節で折る) */
.page__lead { margin: 0 0 32px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

.page__more { padding-bottom: 8px; color: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
.page__more span:first-child { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 6px; }

/* 画像の枠は地を塗らない(前の画像が下へ抜けるあいだ、次の画像の枠から透けて見えるように) */
.page__art { margin: 0; aspect-ratio: 426 / 500; border-radius: 4px; }
.page__art img { display: block; width: 100%; height: 100%; object-fit: cover; background: var(--mute); }

.site a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

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

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

JavaScript
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(SplitText);

const OUT = 0.5; // 1 字が下へ抜ける時間(秒)
const IN = 0.8; // 1 字が上から入る時間(秒)
const EACH = 0.022; // 隣の字とのずれ(秒)
const ROW = 0.06; // 見出しの 2 行目が動き出す遅れ(秒)
const ENTER_AT = 0.28; // 抜け始めてから、次の字が入り始めるまで(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const [site, header, bar, wipe] = [".site", ".site-header", ".site-header__bar", ".wipe"].map((selector) => document.querySelector(selector));
let busy = false;
let shownPath = location.pathname; // いま見せているページ

history.scrollRestoration = "manual"; // スクロール位置は、差し替えるときに自分で戻す

// 現在地の下線の位置と幅(リンクの字の 4px 下)
const underline = (link = header.querySelector('[aria-current="page"]')) =>
  link ? { autoAlpha: 1, x: link.offsetLeft, y: link.offsetTop + link.offsetHeight + 4, width: link.offsetWidth } : { autoAlpha: 0 };
const placeBar = () => gsap.set(bar, underline());
document.fonts.ready.then(placeBar);
addEventListener("resize", placeBar);

// 次のページの 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 down = (i, el) => el.closest(".mask").offsetHeight + 6; // マスクの高さより少し多く動かすと、部品が外に隠れる

async function swap(url, push) {
  const [{ next, title }] = await Promise.all([load(url), document.fonts.ready]); // 字形が決まってから字に分ける
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  const current = document.querySelector("[data-page]");
  current.inert = true; // 抜けていくページには、焦点や読み上げを入れない
  current.after(next); // 同じマスに重ねる(.pages の grid で、どのページも同じマスに置く)
  scrollTo(0, 0);
  const [from, to] = [current, next].map((page) => ({
    lines: [...page.querySelectorAll("[data-chars]")].map((line) => SplitText.create(line, { type: "chars" })), // 見出しは行ごとに字に分ける
    swap: page.querySelectorAll("[data-swap]"), // 小見出し・本文の行・リンク
    art: page.querySelector("[data-art]"),
  }));
  const link = [...header.querySelectorAll("nav a")].find((a) => a.pathname === shownPath) ?? null; // ナビに無いページなら下線を消す
  wipe.dataset.theme = next.dataset.theme; // 地の色は、次のページの色の幕を上から降ろして移す
  // 今のページの部品を下へ抜き、次のページの部品を上から入れる。動きを減らす設定では、ほぼ一瞬で切り替える
  const tl = gsap.timeline().timeScale(reduceMotion ? 20 : 1);
  from.lines.forEach((line, row) => tl.to(line.chars, { y: down, duration: OUT, ease: "power3.in", stagger: EACH }, row * ROW));
  to.lines.forEach((line, row) => tl.from(line.chars, { y: (i, el) => -down(i, el), duration: IN, ease: "power3.out", stagger: EACH }, ENTER_AT + row * ROW));
  await tl
    .to(from.swap, { y: down, duration: 0.45, ease: "power3.in", stagger: 0.03 }, 0.05)
    .from(to.swap, { y: (i, el) => -down(i, el), duration: 0.7, ease: "power3.out", stagger: 0.04 }, ENTER_AT + 0.1)
    .to(from.art, { yPercent: 100, duration: 1, ease: "power3.inOut" }, 0) // 前の画像は下へ送り、次の画像を上から降ろす
    .from(to.art, { yPercent: -100, duration: 1, ease: "power3.inOut" }, 0)
    .fromTo(wipe, { clipPath: "inset(0% 0% 100% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.8, ease: "power2.inOut" }, 0)
    .to(bar, { ...underline(link), duration: 0.8, ease: "power3.inOut" }, 0.05)
    .add(() => {
      header.dataset.theme = next.dataset.theme; // 幕の縁がナビを通るころに、ナビの色と現在地を移す(色は CSS の transition でつなぐ)
      header.querySelector("[aria-current]")?.removeAttribute("aria-current");
      link?.setAttribute("aria-current", "page");
    }, 0.08);
  current.remove();
  site.dataset.theme = next.dataset.theme; // 降りきった色を地に移し、幕を上へ戻す
  gsap.set(wipe, { clipPath: "inset(0% 0% 100% 0%)" });
  to.lines.forEach((line) => line.revert());
  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;
  await swap(url, push).catch(() => location.assign(url)); // 読み込めなかったら、ふつうに移る
  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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。