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

ページ遷移で上端の進捗バーが伸びきると、次のページへ切り替わる

デモ

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

仕様

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

しくみと調整

リンクが押されたら、画面の上端に固定した細いバーを出し、次のページを読み込むあいだ伸ばしていきます。読み込みが終わったらバーを 100% まで伸ばし、本文だけを地の色へ薄く消して差し替え、次の本文を少し下から出します。ヘッダーとバーは残したまま入れ替えるので、画面全体を覆う演出と違い、待ち時間を短く見せたいふだんのサイトにそのまま使える形です。

押した瞬間に 8% を出し、残りは 8 秒の expo.out で 92% へ近づけます。はじめは速く、読み込みが長引くほど遅くなるので、進んでいる感じを保ちながら、終わる前に 100% に届いてしまうことがありません。読み込めたら 0.3 秒の power2.out で 100% へ伸ばし、0.4 秒止めてから消します。本文は 0.1 秒後から 0.15 秒で消し、次の本文は 12px 下から 0.5 秒で出します。

バーの長さは、全幅の帯を左へずらす量(xPercent の -100〜0)で表します。scaleX で縮めると、先端に付けたにじみ(同じ色の影)まで横につぶれて、伸びている先が見えなくなります。バーは失敗したときも 100% にして消し、ふつうのページ移動に切り替えます。消し忘れると、上端に止まったバーが残ります。

読み込むあいだは、本文に aria-busy を付けて、読み上げにも待っていることを伝えます。差し替えたあとは、ページの題名と URL を書き換え、ヘッダーの現在地を新しい URL に合わせ、読み上げとキーボードの位置を新しいページの見出しへ移します。記事のページでは、その親の一覧のリンクを現在地の区分として示しています。

つまみは、バーの色と太さ、押した瞬間の長さ、待つあいだの曲線と時間、止めてから消すまでの間です。太さは 2〜3px が目安です。色は地から浮く色にし、落ち着いた色でも先端のにじみで伸びている先が目に入ります。動きを減らす設定では、バーは進み具合の目印として残し、本文の入れ替えだけを一瞬にします。お知らせや記事、商品の一覧のように、行き来の多いサイトの遷移に向いています。

コード

HTML
<!-- 上端の進捗バー。body の先頭に 1 つだけ置く -->
<div class="progress" aria-hidden="true"><div class="progress__bar"></div></div>

<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/">実績</a></li>
      <li><a href="/about/">私たちについて</a></li>
      <li><a href="/news/" aria-current="page">お知らせ</a></li>
      <li><a href="/contact/">お問い合わせ</a></li>
    </ul>
  </nav>
</header>

<!-- 遷移で差し替えるのは data-page の中だけ(ヘッダーとバーは残す)。次のページも同じ形で書く -->
<main class="page" data-page>
  <p class="page__eyebrow">News</p>
  <h1 class="page__title">お知らせ</h1>
  <ul class="news">
    <li>
      <a class="news__link" href="/news/new-studio/">
        <time datetime="2026-09-18">9月18日</time>
        <strong>福岡に新しいスタジオを開設しました</strong>
        <span>ここに説明文が入ります。</span>
      </a>
    </li>
    <li>
      <a class="news__link" href="/news/tide-coffee/">
        <time datetime="2026-09-04">9月4日</time>
        <strong>潮見珈琲のブランドデザイン</strong>
        <span>ここに説明文が入ります。</span>
      </a>
    </li>
    <li>
      <a class="news__link" href="/news/quiet-interfaces/">
        <time datetime="2026-08-21">8月21日</time>
        <strong>動きを控えた画面の考え方</strong>
        <span>ここに説明文が入ります。</span>
      </a>
    </li>
  </ul>
</main>

CSS
:root {
  --bar-color: #688297; /* バーの色。ページの地から浮く色にする */
  --bar-height: 3px;
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --page-line: rgb(38 38 38 / 0.12);
  --gutter: clamp(24px, 7vw, 96px);
}

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

/* 上端の進捗バー。長さは JS が位置(xPercent)で変える */
.progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  height: var(--bar-height);
  pointer-events: none;
}

.progress__bar { position: relative; height: 100%; background: var(--bar-color); visibility: hidden; }

/* 先端のにじみ。細いバーでも、伸びている先が目に入るようにする */
.progress__bar::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  width: 96px;
  height: 100%;
  box-shadow: 0 0 10px var(--bar-color), 0 0 4px var(--bar-color);
}

.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] { color: var(--page-ink); }
.site-header a[aria-current] { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 6px; }

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

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

.page__title { margin: 0 0 48px; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }

.news { margin: 0; padding: 0; list-style: none; }
.news li { border-top: 1px solid var(--page-line); }

/* 日付・題・ひとことを 1 つのリンクにまとめる(行のどこを押しても移る) */
.news__link { display: grid; grid-template-columns: 160px 1fr; gap: 4px 0; padding: 24px 0; color: inherit; text-decoration: none; }
.news__link time { grid-row: span 2; padding-top: 6px; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; color: var(--page-mute); }
.news__link strong { font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; word-break: auto-phrase; }
.news__link span { font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--page-mute); word-break: auto-phrase; }
.news__link:hover strong { text-decoration: underline; text-underline-offset: 4px; }

@media (max-width: 600px) {
  .news__link { grid-template-columns: 1fr; }
  .news__link time { grid-row: auto; padding-top: 0; }
}

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

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

JavaScript
import { gsap } from "gsap";

const START = 0.08; // 押した瞬間に出す長さ(何かが始まったことを、すぐに見せる)
const LIMIT = 0.92; // 読み込みが終わるまでは、ここへ近づくだけで 100% にしない
const WAIT = 8; // LIMIT に届くまでの時間(秒)。expo.out なので、長引くほど遅くなる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const bar = document.querySelector(".progress__bar");
const at = (n) => ({ xPercent: (n - 1) * 100 }); // 長さ n(0〜1)の位置。scaleX で縮めると、先端のにじみまで縮む
let busy = false;
let shownPath = location.pathname; // いま見せているページ

history.scrollRestoration = "manual"; // スクロール位置は、差し替えたあとに自分で戻す
gsap.set(bar, { ...at(0), autoAlpha: 0 });

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

// 待つあいだ: 押した瞬間に少し出し、あとは減速しながら LIMIT へ近づける
function begin() {
  gsap.killTweensOf(bar);
  gsap.fromTo(bar, { ...at(START), autoAlpha: 1 }, { ...at(LIMIT), duration: WAIT, ease: "expo.out" });
}

// 読み込めたら(失敗しても)100% まで伸ばし、少し止めてから消す
function end() {
  gsap.killTweensOf(bar);
  gsap.timeline().to(bar, { ...at(1), duration: 0.3, ease: "power2.out" }).to(bar, { autoAlpha: 0, duration: 0.25 }, "+=0.4");
}

// ヘッダーの現在地を新しい URL に合わせる(記事のページでは、親の一覧を現在地の区分にする)
function markCurrent() {
  document.querySelectorAll(".site-header__links a").forEach((link) => {
    const path = new URL(link.href).pathname;
    const here = location.pathname === path ? "page" : location.pathname.startsWith(path) ? "true" : null;
    if (here) link.setAttribute("aria-current", here);
    else link.removeAttribute("aria-current");
  });
}

async function swap(url, push) {
  const current = document.querySelector("[data-page]");
  current.setAttribute("aria-busy", "true"); // 読み込み中であることを、読み上げにも伝える
  begin();
  const { next, title } = await load(url).finally(end);
  // 本文だけを地の色へ薄く消してから差し替える(ヘッダーとバーは残す)
  await gsap.to(current, { opacity: 0, duration: reduceMotion ? 0 : 0.15, delay: reduceMotion ? 0 : 0.1 });
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  current.replaceWith(next);
  document.title = title;
  scrollTo(0, 0);
  markCurrent();
  // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  const heading = next.querySelector("h1");
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
  // 次の本文は、少し下から薄く出す。動きを減らす設定では、すぐに見せる
  if (!reduceMotion) gsap.fromTo(next, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out", clearProps: "opacity,transform" });
}

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