デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
階層の深いパンくずリストを、枠の幅に収まらなくなった順に畳んで「…」ひとつにまとめます。「…」を押すと、畳んだ項目と区切りの記号が上から順に開いて伸び、狭いままなら 2 行に折り返します。項目ごとに「その項目を残すのに要る幅」を先に測っておき、今の幅と比べるだけなので、幅が変わるたびに畳む数も変わります。
1 つの項目は、幅が 40px 変わるあいだに畳みきります。ふっと消えるのではなく、名前と区切りの記号が一緒に縮んでいくので、どこが畳まれたのかが見えます。開くのは 0.5 秒で、項目ごとに少しずつ遅らせて上の段から出します。先頭と末尾(今のページ)は畳まないので、どこから来てどこにいるかは、いちばん狭いときでも残ります。
気をつけるのは、1 つ畳んでも幅が縮むとは限らないことです。「…」自身にも幅があるので、短い項目を 1 つ隠しただけでは、かえって広くなることがあります。要る幅をそのまま比べると、順番を飛ばして真ん中の項目から消えてしまいます。比べる相手は「そこまでの要る幅のうち、いちばん小さいもの」にしてください。こうすると、畳む数は必ず前から順に増えます。
並びは nav の中の順序つきの箇条書きで書き、今のページの項目に aria-current を付けます。畳んだ項目は幅が 0 になっても要素としては残るので、inert を付けて Tab と読み上げから外します。「…」のボタンには開いているかどうかを持たせ、名前は見えない文字で添えます(記号だけでは、何のボタンか伝わりません)。
幅は ResizeObserver で受け取り、高さの変わったときは測り直しません。折り返しで高さが変わるたびに呼び戻されて、止まらなくなるためです。残す項目の数、畳むときの手ざわり、「…」の代わりに言葉を出すかどうかが主なつまみです。商品の深い階層、ドキュメント、管理画面のようにパンくずが長くなるページに向いています。
コード
<!-- 枠の右下をつまむと幅を変えられる(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>
.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; }
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);
});
});