パンくずリスト 9選 — 階層が深くても崩れない
パンくずリストは、今開いているページがサイトのどこにあるかをホームから順に経路で示すナビゲーションです。階層が深くても崩れない9種を、実際に動くコードで配ります。9つのうち1つは画面の経路とは別に検索エンジン用の経路をもう一組持ち、もう1つは画面が狭くなると経路全体を戻るボタン1つにたたみます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 区切り文字 3種
- 02 中間階層をたたむメニュー
- 03 現在ページの強調
- 04 兄弟階層のプレビュー
- 05 幅に合わせた自動短縮
- 06 アイコンと文字の組み合わせ
- 07 階層移動のアニメーション
- 08 構造化データの経路
- 09 モバイルの戻るボタン型
並び順は好みではなく、利用者が画面の上で経路をたどる順です。最初の3つは見た目です。区切りを変えて雰囲気を替え(01)、経路が長くなったら中間をたたみ(02)、自分が今どの項目にいるのかをはっきり見せます(03)。次の2つは移動です。同じ層の隣のカテゴリを先に見せ(04)、幅が縮むと前の階層から省略記号の後ろに隠します(05)。6番目は情報を圧縮し(06)、7番目は階層間の移動に動きを付けます(07)。最後の2つは画面の外の話です。検索エンジンが読む経路を別に持ち(08)、狭い画面では1つ上だけを残します(09)。9つすべてを nav と ol・li のマークアップの上に作り、どれにもマウスだけでなくキーボード操作がきちんと組み込まれています。
01区切り文字 3種
同じ経路がスラッシュ、山かっこ、中黒の3種類の区切りで切り替わります。区切りはマークアップに入れず li:not(:last-child)::after の content だけで描くので、スクリーンリーダーには項目名だけが届きます。ピルボタンを押せばその区切りに固定され、左右の矢印キーでも切り替えられます。
function pick(i) {
root.classList.remove('is-demo');
ind.style.setProperty('--i', i);
pills.forEach(function (p, n) { p.setAttribute('aria-pressed', n === i ? 'true' : 'false'); });
lists.forEach(function (l, n) {
var on = n === i;
l.classList.toggle('is-on', on);
l.setAttribute('aria-hidden', on ? 'false' : 'true');
[].forEach.call(l.querySelectorAll('a'), function (a) { a.tabIndex = on ? 0 : -1; });
});
}
02中間階層をたたむメニュー
経路が4階層を超えると、中間の階層が省略記号ボタン1つにたたまれます。ボタンを押すと隠れた階層がリストで下りてきて、Escape で閉じられ、リストの中では上下の矢印キーで移動できます。開閉の状態は aria-expanded がそのまま伝えます。
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); setOpen(false, true); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
var i = links.indexOf(document.activeElement);
if (i < 0 && document.activeElement !== btn) return;
e.preventDefault();
if (!isOpen()) { setOpen(true, false); return; }
var step = e.key === 'ArrowDown' ? 1 : links.length - 1;
links[(i + step + links.length) % links.length].focus({ preventScroll: true });
});
03現在ページの強調
経路の最後だけがリンクではない文字として残り、太字と下線が一緒に付きます。その項目の aria-current="page" がスクリーンリーダーに「今はここ」と伝え、下線は text-underline-offset で文字から少し離してあります。前の項目を押すと、そこが新しい現在ページになり、その後ろの項目はまとめて隠れます。
function makeCurrent(i) {
root.classList.remove('is-demo');
at = i;
lis.forEach(function (li, n) {
li.hidden = n > i;
var c = crumbs[n];
c.classList.toggle('ce__cur', n === i);
if (n === i) c.setAttribute('aria-current', 'page');
else c.removeAttribute('aria-current');
});
val.textContent = crumbs[i].textContent;
}
04兄弟階層のプレビュー
中間の階層にマウスを乗せるか下矢印キーを押すと、同じ層の別カテゴリがドロップダウンで先に現れます。上の階層に戻って降り直さなくても横へ直接移動できる仕組みで、リストの中でも上下の矢印キーで移動できます。項目には aria-haspopup と aria-expanded が付いています。
function open(first) {
live();
item.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
if (first) opts[0].focus({ preventScroll: true });
}
function shut(back) {
item.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
if (back) btn.focus({ preventScroll: true });
}
item.addEventListener('pointerenter', function () { open(false); });
item.addEventListener('pointerleave', function () { shut(false); });
05幅に合わせた自動短縮
パネルの幅が縮むと、ホームの次の3つの階層が前から順に省略記号の後ろに隠れ、経路が2行になることはありません。ResizeObserver が幅を見張り、リストの scrollWidth が nav の幅より大きくなった瞬間にその項目へ hidden を付け、幅が戻れば逆順で戻します。ハンドルを直接ドラッグして同じ挙動を試せます。
function fit() {
list.style.maxWidth = 'none'; // 測っている間だけ自然な幅で
hideables.forEach(function (el) { el.hidden = false; });
dots.hidden = true;
for (var i = 0; i < hideables.length && list.scrollWidth > nav.clientWidth; i++) {
dots.hidden = false;
hideables[i].hidden = true;
}
list.style.maxWidth = '';
}
new ResizeObserver(fit).observe(panel);
06アイコンと文字の組み合わせ
先頭はホームアイコンだけ、中間はフォルダアイコンと文字、最後はタグアイコンと文字を現在ページの塗りつぶしで見せて、階層ごとに情報量が変わります。アイコンは currentColor で文字色をそのまま受け継ぐため、項目がどの状態でも色が崩れません。下のボタンで中間の文字をたたんだり広げたりでき、どの項目を押しても aria-current がそこへ移ります。
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = root.classList.toggle('is-compact');
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
toggle.textContent = on ? 'アイコンと文字' : 'アイコンのみ';
});
07階層移動のアニメーション
上の階層を押すと、その後ろにあった項目が一斉に左へ滑り出て、再度降りるときは同じ項目が右から滑り込みます。項目が出ていくアニメーションが終わる animationend の瞬間に hidden を付けるので、ドキュメントの大きさは揺れません。
list.addEventListener('animationend', function (e) {
var li = e.target.closest('.st__item');
if (!li) return;
if (li.classList.contains('is-out')) { li.hidden = true; li.classList.remove('is-out'); }
else if (li.classList.contains('is-in')) { li.classList.remove('is-in'); }
});
list.addEventListener('click', function (e) {
var a = e.target.closest('.st__link');
if (!a || !a.hasAttribute('data-i')) return;
e.preventDefault();
stop();
for (var k = items.length - 1; k > +a.getAttribute('data-i'); k--) {
if (!items[k].hidden) leave(items[k]);
}
});
08構造化データの経路
画面の経路と同じ内容を BreadcrumbList の JSON-LD でもう一組持ちます。ドキュメントの中には実際の script タグがあり、ボタンを押すと画面の ol を実際に読んで position・name・item を作り直します。上のカードは、その経路が検索結果でどう表示されるかを先に見せます。
function build() {
var out = [], n = 0;
for (var i = 0; i < all.length; i++) {
if (all[i].hidden) continue;
var a = all[i].querySelector('.jl__crumb');
n++;
out.push({ "@type": "ListItem", position: n, name: a.textContent.trim(),
item: "https://godrichstory.com/" + a.getAttribute('data-seg') });
}
ld.textContent = JSON.stringify({ "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: out });
}
09モバイルの戻るボタン型
幅が狭くなると、経路全体が1つ上の階層だけを示す戻る行にたたまれます。matchMedia で実際の画面幅を常に監視して自動で切り替え、幅が戻れば経路全体が再び展開します。戻る行には aria-label でどこへ戻るのかを明記してあります。
var mq = window.matchMedia('(max-width: 420px)');
function apply(narrow) {
root.classList.remove('is-demo');
root.classList.toggle('is-narrow', narrow);
chips.wide.setAttribute('aria-pressed', String(!narrow));
chips.narrow.setAttribute('aria-pressed', String(narrow));
}
function reflect() { apply(manual === null ? mq.matches : manual); }
if (mq.addEventListener) { mq.addEventListener('change', function () { manual = null; reflect(); }); }
どこで壊れるか — 落とし穴
最も長くかかったのは05番の「測る瞬間」でした。リストに max-width: 100% を付けると最後の項目が勝手に折り返し表示に変わり、scrollWidth が実際より小さく出るため、パネルをどれだけ狭くしても階層がまったくたたまれない状態でした。測っている間だけ maxWidth を解除して自然な幅で測り、終わってから戻す3行を入れて、ようやく項目が動き始めました。レンダラーの計測で確認すると、幅が209pxに縮んだ瞬間にホームの次の3階層が省略記号の後ろに隠れ、311pxに戻ると最初にノートPCが戻ってきました。隠す側にも落とし穴があり、hidden を付けても flex の子は消えないことがあるため、[hidden] { display: none } を自分で書く必要がありました。
2番目は見えないリストの高さです。02番と04番の開くリストは絶対配置なので親の高さ計算から外れるのに scrollHeight にはそのまま数えられてしまい、320pxの画面でスクロールバーが出ました。ルートにリストの高さ分の padding-bottom を先に確保しておくことで、どちらの画面にも収まります。3番目は動きを写す静止画でした。06番は項目が浮かび上がる間のフェードの区間が長く、代表画像に2つの状態が半透明で重なって写りました。フェードが行き来する区間を2フレームに圧縮すると、強度は46から91.9まで上がりました。状態が変わる場面は、ふわっと重ねるのではなく1フレームで切り替えるのが正解です。こうして整えた9組のソースは、圧縮パスワード vfj8frcg を入れると開く zip にバニラ版と React 版の両方が同じ値で入っています。
アクセシビリティ
9つすべてで nav に aria-label を付け、項目は ol・li で並べ、区切りはスクリーンリーダーが読み上げない擬似要素と aria-hidden を付けた図形だけで描いています。現在ページを伝える aria-current は9つすべてに入っていて、03・06・08番は項目を押すたびにその位置を移し、たたまれるリストを開く02・04番は aria-expanded で開閉状態を知らせます。キーボードもマウスと同じ道を通ります。01番はピルボタンと左右の矢印キー、02・04番は下矢印キーで開いてリスト内を上下に移動し Escape で閉じ、05番はハンドルで左右の矢印キー、09番は戻る行が Enter で動きます。フォーカスを移すコードはすべて preventScroll 付きにして、デモがページをスクロールさせないようにしました。prefers-reduced-motion: reduce ではグリッド用の自動再生とトランジションを止め、実際の操作と状態の値はそのまま残します。aria-current の使い方はMDN の aria-current ページに、幅の監視はMDN の ResizeObserver ページにまとまっています。
道案内の部品をさらに探すならナビゲーションカテゴリに、この記事のように JS で動く部品はJS カテゴリに集まっています。
FAQ
パンくずリストは検索表示に本当に効きますか?
画面に描くだけでは不十分で、08番のように BreadcrumbList の構造化データを併せて置くと検索エンジンが経路を理解します。ページが再クロールされた後、検索結果のURLの代わりに経路が並び始めますが、その前に構造化データが存在している必要があります。このデモのように画面のリストから配列を作れば、2つの経路がずれることはありません。
最後の項目もリンクにしてはいけませんか?
現在ページから自分自身へのリンクは同じページをめぐる循環になるので、お勧めしません。最後はリンクではない文字にして aria-current="page" を付ければ、目にもスクリーンリーダーにも今の場所がはっきり伝わります。03番がまさにその形です。
区切りをマークアップに文字で入れてもいいですか?
動きはしますが、スラッシュや山かっこが1項目ずつ読み上げられ、経路が断片に分かれて聞こえます。区切りは li:not(:last-child)::after の content か aria-hidden を付けた図形で描く方がよく、01番がその違いを3種類の区切りで一度に見せてくれます。