本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

狭い画面でパンくずリストが「…」に畳まれ、押すと中間の階層が開いて伸びる

デモ

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

仕様

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

しくみと調整

階層の深いパンくずリストを、枠の幅に収まらなくなった順に畳んで「…」ひとつにまとめます。「…」を押すと、畳んだ項目と区切りの記号が上から順に開いて伸び、狭いままなら 2 行に折り返します。項目ごとに「その項目を残すのに要る幅」を先に測っておき、今の幅と比べるだけなので、幅が変わるたびに畳む数も変わります。

1 つの項目は、幅が 40px 変わるあいだに畳みきります。ふっと消えるのではなく、名前と区切りの記号が一緒に縮んでいくので、どこが畳まれたのかが見えます。開くのは 0.5 秒で、項目ごとに少しずつ遅らせて上の段から出します。先頭と末尾(今のページ)は畳まないので、どこから来てどこにいるかは、いちばん狭いときでも残ります。

気をつけるのは、1 つ畳んでも幅が縮むとは限らないことです。「…」自身にも幅があるので、短い項目を 1 つ隠しただけでは、かえって広くなることがあります。要る幅をそのまま比べると、順番を飛ばして真ん中の項目から消えてしまいます。比べる相手は「そこまでの要る幅のうち、いちばん小さいもの」にしてください。こうすると、畳む数は必ず前から順に増えます。

並びは nav の中の順序つきの箇条書きで書き、今のページの項目に aria-current を付けます。畳んだ項目は幅が 0 になっても要素としては残るので、inert を付けて Tab と読み上げから外します。「…」のボタンには開いているかどうかを持たせ、名前は見えない文字で添えます(記号だけでは、何のボタンか伝わりません)。

幅は ResizeObserver で受け取り、高さの変わったときは測り直しません。折り返しで高さが変わるたびに呼び戻されて、止まらなくなるためです。残す項目の数、畳むときの手ざわり、「…」の代わりに言葉を出すかどうかが主なつまみです。商品の深い階層、ドキュメント、管理画面のようにパンくずが長くなるページに向いています。

コード

HTML
<!-- 枠の右下をつまむと幅を変えられる(CSS の resize)。狭くすると、畳む数が変わる -->
<article class="doc">
  <div class="doc__bar">
    <p class="doc__logo">Hi! I'm HIGASHIYUKI®</p>
    <span class="doc__burger" aria-hidden="true"></span>
  </div>

  <nav class="crumbs" aria-label="パンくずリスト">
    <ol class="crumbs__list">
      <li class="crumbs__item"><a href="/">ホーム</a></li>
      <!-- 畳んだ項目の代わりに、ここへ「…」が 1 つだけ出る -->
      <li class="crumbs__item crumbs__item--dots">
        <button class="crumbs__dots" type="button" aria-expanded="false">
          <span aria-hidden="true">…</span><span class="crumbs__sr">途中の階層を表示</span>
        </button>
      </li>
      <li class="crumbs__item"><a href="/shop/">商品一覧</a></li>
      <li class="crumbs__item"><a href="/shop/furniture/">家具</a></li>
      <li class="crumbs__item"><a href="/shop/furniture/chairs/">チェア</a></li>
      <li class="crumbs__item"><a href="/shop/furniture/chairs/lounge/">ラウンジチェア</a></li>
      <li class="crumbs__item" aria-current="page">オークのラウンジチェア</li>
    </ol>
  </nav>

  <h1 class="doc__title">オークのラウンジチェア</h1>
  <div class="doc__art"></div>
  <p class="doc__meta">¥68,000(税込)・9月26日入荷</p>
</article>

CSS
.doc {
  --ink: #262626; /* 見出しと今のページの名前 */
  --mute: #6b6b6b; /* 親の階層のリンクと区切りの記号 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --surface: #ffffff; /* 面 */
  --sep: 17px; /* 区切りの記号の幅。項目の幅はこれを含めて測る */
  box-sizing: border-box;
  /* 右下をつまんで幅を変えられる(resize は overflow が visible 以外のときだけ効く) */
  width: min(760px, 100%);
  min-width: 300px;
  margin: 48px auto;
  border: 1px solid var(--line);
  border-radius: 4px;
  overflow: hidden;
  resize: horizontal;
  background-color: var(--surface);
  color: var(--ink);
  font: 400 15px/1.5 system-ui, sans-serif;
}

.doc__bar { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); }
.doc__logo { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap; }
/* 3 本線のメニュー(幅 24px・線 2px・間 6px) */
.doc__burger { flex: none; width: 24px; height: 18px; background-image: linear-gradient(var(--ink), var(--ink)), linear-gradient(var(--ink), var(--ink)), linear-gradient(var(--ink), var(--ink)); background-size: 100% 2px; background-position: 0 0, 0 8px, 0 16px; background-repeat: no-repeat; }

.crumbs { padding: 24px 24px 0; }
.crumbs__list { display: flex; flex-wrap: wrap; align-items: center; row-gap: 8px; margin: 0; padding: 0; list-style: none; font-size: 15px; line-height: 28px; letter-spacing: 0.02em; }

/* 1 つの項目は「区切りの記号 + 名前」の組。幅を縮めれば、記号も名前も一緒に畳まれる */
.crumbs__item { box-sizing: border-box; display: flex; align-items: center; overflow: hidden; font-weight: 600; white-space: nowrap; }
.crumbs__item + .crumbs__item::before { content: "/"; flex: none; width: var(--sep); color: var(--mute); font-weight: 400; text-align: center; }
.crumbs__item a { color: var(--mute); text-decoration: none; }
.crumbs__item a:hover { color: var(--ink); text-decoration: underline; }
.crumbs__item[aria-current="page"] { color: var(--ink); font-weight: 700; }

.crumbs__dots { flex: none; padding: 0 12px; border: 0; border-radius: 4px; background-color: rgba(38, 38, 38, 0.06); color: var(--mute); font: inherit; font-weight: 700; font-size: 15px; line-height: 28px; cursor: pointer; }
.crumbs__dots:hover { background-color: rgba(38, 38, 38, 0.12); }
/* 読み上げ用の名前。見えないが、読み上げと検索には届く */
.crumbs__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.doc__title { margin: 28px 24px 0; word-break: auto-phrase; font-size: clamp(24px, 4vw, 30px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; }
.doc__art { height: 200px; margin: 20px 24px 0; border-radius: 4px; background-image: linear-gradient(135deg, #c68b43, #35607c); }
.doc__meta { margin: 16px 24px 24px; color: var(--mute); font-size: 13px; letter-spacing: 0.04em; }

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

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const BAND = 40; // 1 つの項目が畳みきるまでの、幅の変化の幅(px)

document.querySelectorAll(".crumbs").forEach((crumbs) => {
  const list = crumbs.querySelector(".crumbs__list");
  const dots = list.querySelector(".crumbs__item--dots");
  const button = dots.querySelector(".crumbs__dots");
  const items = [...list.children].filter((item) => item !== dots);
  const state = { expand: 0 }; // 「…」を押して開いた進み
  let natural = [];
  let chipWidth = 0;
  let threshold = [];
  let width = 0;
  let open = null;

  // もとの幅を測る(入れてある幅をいったん外す。折り返していても、項目 1 つ分の幅は変わらない)
  const measure = () => {
    [...list.children].forEach((item) => (item.style.width = ""));
    natural = items.map((item) => item.offsetWidth);
    chipWidth = dots.offsetWidth;
    // need[k] = 中間を k 個畳んだときに要る幅。「…」自身にも幅があるので、need は単純に減らない
    const need = items.map((item, k) => natural[0] + (k > 0 ? chipWidth : 0) + natural.reduce((sum, w, j) => (j > k ? sum + w : sum), 0));
    threshold = items.map((item, i) => Math.min(...need.slice(0, Math.max(1, i))));
  };

  // 使える幅と「開いた進み」から、項目と「…」の幅・濃さを決める
  const layout = () => {
    const room = list.clientWidth;
    const last = items.length - 1;
    const shows = items.map((item, i) => {
      // 先頭と末尾は畳まない。中間は「その項目を残すのに要る幅」に足りなくなった順に畳む
      const fit = i === 0 || i === last ? 1 : gsap.utils.clamp(0, 1, (room - threshold[i]) / BAND);
      // 押して開いたときは、上の段から順に戻す
      const shown = gsap.utils.clamp(0, 1, (state.expand - (i - 1) * 0.14) / 0.42);
      return Math.max(fit, shown);
    });
    shows.forEach((show, i) => {
      items[i].style.width = `${Math.round(natural[i] * show)}px`;
      items[i].style.opacity = show;
      items[i].inert = show < 0.5; // 畳んだ項目は、Tab と読み上げから外す
    });
    const chip = 1 - Math.min(...shows.slice(1, last));
    dots.style.width = `${Math.round(chipWidth * chip)}px`;
    dots.style.opacity = chip;
    dots.inert = chip < 0.5;
    button.setAttribute("aria-expanded", String(state.expand > 0.5));
  };

  button.addEventListener("click", () => {
    open?.kill();
    open = gsap.to(state, { expand: 1, duration: 0.5 / SPEED, ease: "power2.out", onUpdate: layout });
  });

  // 幅が変わったら測り直して畳み直す。高さの変化(折り返し)では測り直さない
  const observer = new ResizeObserver(() => {
    if (list.clientWidth === width) return;
    width = list.clientWidth;
    open?.kill();
    state.expand = 0;
    measure();
    layout();
  });
  document.fonts.ready.then(() => {
    measure();
    layout();
    observer.observe(list);
  });
});

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

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