パンくずリストをReactで実装:aria-currentとJSON-LDで現在地とSEO対応
色だけの現在地表示は支援技術に伝わらない。aria-current・BreadcrumbListのJSON-LD・パスからの自動生成まで、コピペで動くReactコードで作る手順を僕の失敗込みで解説。
「パンくず、それっぽく置いといて」。そう頼んで出てきたのが Home > Blog > claude-code-breadcrumb-navigation でした。見た目は問題ない。でもよく見ると、最後のslugが生のまま出ていて、現在地は色が少し濃いだけ。スクリーンリーダーで読ませたら「リンク、リンク、リンク」と平坦に読み上げるだけで、「今ここにいる」がどこにも伝わっていませんでした。
パンくずって、記事の頭にちょこんと乗っている地味な部品ですよね。だから軽く見られがちなんですが、ここはサイトの階層、内部リンク、アクセシビリティ、Google検索の表示が一か所に集まる交差点です。雑に作ると、見た目はOKでも裏側がぜんぶ静かに壊れます。
この記事では、Reactでアクセシブルなパンくずを作り、aria-current で現在地を伝え、BreadcrumbList のJSON-LDで検索エンジンにも階層を渡し、さらにURLパスから自動生成するところまで、僕がやらかした失敗込みで書いていきます。
この記事の要点
- 現在ページは色ではなく
aria-current="page"で伝える。見た目だけの強調は支援技術にも自動テストにも届かない。 navにaria-label、区切り記号にはaria-hidden="true"。これでナビゲーションランドマークとして正しく認識される。- 構造化データは
BreadcrumbListのJSON-LD。画面表示と同じ配列から作り、URLは絶対URLにそろえる。 - URLパスからパンくずを自動生成すると手書きが消える。ただしslugは人間向けラベルに整形する。
- 画面とJSON-LDを別々の配列で作ると、カテゴリ変更時に片方だけ腐る。情報源は1つにする。
まず「パンくずは何のためか」をはっきりさせる
パンくずは「戻るボタンの代わり」ではありません。ブラウザの戻るは直前の履歴に戻るだけですが、パンくずが示すのはサイト構造の中の現在地です。記事から一段上のカテゴリへ、カテゴリからトップへ。今いる場所と、上に何があるかを地図のように見せる部品です。
そして、この地図は人間だけが読むものではありません。検索エンジンも読みます。Googleはパンくずの構造化データを読み取って、検索結果のタイトル下に claudecode-lab.com › 記事 › Claude Code のような階層表示を出すことがあります。ここがきれいだと、検索結果でのクリック率にも効いてきます。
だから設計で押さえる軸は3つです。人間に現在地を見せる(見た目)、支援技術に現在地を伝える(アクセシビリティ)、検索エンジンに階層を渡す(構造化データ)。この3つを別々に作ると必ずズレるので、最初から1セットで考えます。
実装に入る前に、迷いやすいポイントを表で潰しておきます。Claude Codeに頼むときも、この表の右側を先に渡すと出戻りが減ります。
| 項目 | 決めること | やりがちな失敗 |
|---|---|---|
| ラベル | slugをそのまま出すか、記事タイトルやカテゴリ名を使うか | claude-code-breadcrumb-navigation が生で表示される |
| URL | JSON-LDのURLを絶対URLにそろえられるか | 構造化データに /blog/x のような相対URLが混ざる |
| 現在ページ | リンクにするか、ただのテキストにするか | aria-current がなく、色の濃さだけで現在地を表す |
| モバイル | 全階層を見せるか、中間を省略するか | 長いタイトルが2〜3行に折り返して本文を下へ押す |
| 多言語 | /en/ や /ko/ のロケールでラベルをどう切り替えるか | 英語ページに日本語ラベルが残る |
aria-current と aria-label の置き場所
ここがパンくずの本体です。WAI-ARIAのBreadcrumb Patternでは、パンくず全体を nav 要素(ナビゲーションランドマーク)に入れ、ラベルを付け、現在ページには aria-current="page" を設定する、という考え方が示されています。要点を分解すると、置き場所は3か所です。
nav要素にaria-label。ページには複数のナビゲーションがあるので、「これはパンくず」と名前を付けます。aria-label="パンくずリスト"のように。- 現在ページに
aria-current="page"。最後の項目が「今ここ」だと、支援技術に明示します。色や太字は目で見える人にしか届きません。 - 区切り記号に
aria-hidden="true"。スラッシュや矢印はただの飾りなので、読み上げ対象から外します。これがないと「スラッシュ」と一個ずつ読まれて、耳障りです。
aria-current には page のほかに step、location、true などの値があります。属性そのものの仕様はMDNのaria-currentが詳しいです。パンくずで現在ページを指すなら page が素直な選択です。
ひとつ補足を。現在ページをリンクにしない(ただのテキストにする)実装なら、APGの定義上は aria-current は必須ではありません。それでも僕は付けています。理由は単純で、自動テストで「現在地」を一発で取れるからです。getByRole や属性セレクタで拾えると、E2Eがぐっと書きやすくなります。
コピペで動くReactコンポーネント
ここまでの3点を全部入れたコンポーネントです。Next.jsなら components/Breadcrumb.tsx、AstroのReact integrationでも同じ場所に置けます。siteUrl は https://example.com のように末尾スラッシュなしで渡してください。画面のリストとJSON-LDを同じ items から作るのがキモです。
import type { ReactNode } from "react";
export type BreadcrumbItem = {
label: string;
href: string;
};
type BreadcrumbProps = {
items: BreadcrumbItem[];
siteUrl: string;
ariaLabel?: string;
};
// 相対hrefをsiteUrl基準の絶対URLにそろえる(JSON-LD用)
function toAbsoluteUrl(siteUrl: string, href: string) {
return new URL(href, siteUrl).toString();
}
function Separator(): ReactNode {
// 区切り記号は飾りなので読み上げ対象から外す
return (
<span className="breadcrumb__separator" aria-hidden="true">
/
</span>
);
}
export function Breadcrumb({
items,
siteUrl,
ariaLabel = "パンくずリスト",
}: BreadcrumbProps) {
// 項目が1つ以下ならパンくずを出さない(トップページなど)
if (items.length <= 1) return null;
// 画面表示と同じitemsからBreadcrumListを組み立てる
const jsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@id": toAbsoluteUrl(siteUrl, item.href),
name: item.label,
},
})),
};
return (
<>
<nav className="breadcrumb" aria-label={ariaLabel}>
<ol className="breadcrumb__list">
{items.map((item, index) => {
const isCurrent = index === items.length - 1;
return (
<li className="breadcrumb__item" key={item.href}>
{index > 0 ? <Separator /> : null}
{isCurrent ? (
// 最後の項目は現在地。リンクにせずaria-currentで明示
<span className="breadcrumb__current" aria-current="page">
{item.label}
</span>
) : (
<a className="breadcrumb__link" href={item.href}>
{item.label}
</a>
)}
</li>
);
})}
</ol>
</nav>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</>
);
}
このコンポーネントに items を渡せば、画面のパンくずとJSON-LDが両方出ます。<ol> を使っているのは、パンくずが順序のあるリストだからです。<ul> でも動きますが、「上位→下位」という順序に意味があるので <ol> のほうが素直です。区切り記号を <li> の中に入れているのも理由があって、こうしておくとモバイルで項目を丸ごと隠すときに区切りも一緒に消えてくれます。
URLパスからパンくずを自動生成する
毎ページ items を手で書くのは現実的じゃありません。記事が増えるたびに配列を足すなんて、絶対どこかで忘れます。なので、現在のURLパスからパンくずを組み立てるユーティリティを用意します。
ただし pathname.split("/") をそのまま使うと、URLエンコード(%E8%A8%98%E4%BA%8B みたいなやつ)、クエリ文字列、末尾スラッシュ、そして生slugの表示で崩れます。最低限の整形と、ラベルを差し替えるための辞書を持たせておきます。
import type { BreadcrumbItem } from "@/components/Breadcrumb";
export type BreadcrumbLabels = Record<string, string>;
// "claude-code-x" → "Claude Code X" のように人間向けへ整形
function titleize(segment: string) {
return decodeURIComponent(segment)
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
export function buildBreadcrumbs(
pathname: string,
labels: BreadcrumbLabels = {},
): BreadcrumbItem[] {
// クエリ・ハッシュ・末尾スラッシュを落とす
const cleanPath = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
const segments = cleanPath.split("/").filter(Boolean);
// 先頭は必ずホーム
const items: BreadcrumbItem[] = [
{ label: labels["/"] ?? "ホーム", href: "/" },
];
let href = "";
for (const segment of segments) {
href += `/${segment}`;
// フルパス辞書 → セグメント辞書 → 自動整形 の順で採用
items.push({
label: labels[href] ?? labels[segment] ?? titleize(segment),
href,
});
}
return items;
}
labels は、フルパス単位("/blog": "記事")でもセグメント単位("claude-code-breadcrumb-navigation": "パンくずリスト実装")でも上書きできます。優先順位はフルパスが先。同じslugが別の場所に出るケースで、文脈に合った名前を当てられます。
CMSやAstroのcontent collectionsを使っているなら、最後の項目だけは記事frontmatterの title を使うのが一番自然です。一覧で機械的に整形したラベルより、記事に付けた正式タイトルのほうが読者にも検索エンジンにも親切です。
Next.jsとAstroでの渡し方
組み立てたら、フレームワーク側で items を渡すだけです。Next.js App Routerのサーバーコンポーネントなら、CMSやDBから取った正式タイトルをそのままラベル辞書に入れられます。
import { Breadcrumb } from "@/components/Breadcrumb";
import { buildBreadcrumbs } from "@/lib/breadcrumbs";
const siteUrl = "https://claudecode-lab.com";
export default async function ArticlePage() {
const pathname = "/blog/claude-code-breadcrumb-navigation";
// 最後のラベルは記事の正式タイトルを使う
const labels = {
"/": "ホーム",
"/blog": "記事",
"/blog/claude-code-breadcrumb-navigation": "パンくずリスト実装",
};
const items = buildBreadcrumbs(pathname, labels);
return (
<main>
<Breadcrumb items={items} siteUrl={siteUrl} ariaLabel="パンくずリスト" />
<h1>パンくずリストをReactで実装する</h1>
</main>
);
}
実務では pathname を手書きし続けず、ルート定義・CMSのslug・カテゴリ階層から組み立てます。Astroなら Astro.url.pathname、React Routerなら useLocation() で現在パスが取れます。ここで大事なのは、「どのデータが正か」を1つに決めること。情報源が曖昧だと、画面とJSON-LDが静かにズレます。
Astroだけで完結させたいなら、Reactを入れずに .astro コンポーネントへ同じ発想を移せます。静的ブログではこのほうが軽く、ビルド時にJSON-LDまで吐けます。set:html={JSON.stringify(jsonLd)} でスクリプトを出し、Astro.props で items と siteUrl を受け取る形にすれば、Reactコンポーネントとほぼ同じ構造になります。
モバイルでパンくずが本文を押し下げないCSS
スマホだと、長い記事タイトルが横幅をすぐ食い尽くします。全階層を無理に見せると2行、3行に折り返して、肝心の導入文が画面の下へ追いやられます。そこで、ホーム・直前カテゴリ・現在ページだけ残して、中間をCSSで省略します。
.breadcrumb__list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.breadcrumb__current {
color: #111827;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.breadcrumb__list {
flex-wrap: nowrap;
}
/* 先頭と末尾2つ以外(=中間階層)を隠す */
.breadcrumb__item:not(:first-child):not(:nth-last-child(-n + 2)) {
display: none;
}
/* 隠した位置に「…」を出して省略を示す */
.breadcrumb__item:nth-last-child(2)::after {
color: #9ca3af;
content: "...";
margin-inline: 0.35rem;
}
.breadcrumb__current {
max-width: 58vw;
}
}
ポイントは、省略はCSSだけでやって、DOMとJSON-LDには全階層を残すことです。画面で中間を隠しても、構造化データまで削る必要はありません。逆に、画面に出していない架空のカテゴリをJSON-LDにだけ足すのもダメです。Googleのパンくず構造化データのガイドラインでも、ページに見えるものと構造化データを一致させる前提になっています。
境界値をVitestで押さえる
パンくずの失敗は、見た目を眺めるだけだと素通りします。だから buildBreadcrumbs は境界値をテストで固めます。ルートだけ、クエリ付き、ローカライズ辞書あり、の3パターンが効きます。
import { describe, expect, it } from "vitest";
import { buildBreadcrumbs } from "./breadcrumbs";
describe("buildBreadcrumbs", () => {
it("ルートではホームだけ返す", () => {
expect(buildBreadcrumbs("/")).toEqual([{ label: "ホーム", href: "/" }]);
});
it("ネストを組み立て、クエリ文字列は無視する", () => {
expect(buildBreadcrumbs("/blog/claude-code?page=2")).toEqual([
{ label: "ホーム", href: "/" },
{ label: "Blog", href: "/blog" },
{ label: "Claude Code", href: "/blog/claude-code" },
]);
});
it("辞書があればローカライズしたラベルを使う", () => {
expect(
buildBreadcrumbs("/blog/claude-code-breadcrumb-navigation", {
"/": "ホーム",
"/blog": "記事",
"/blog/claude-code-breadcrumb-navigation": "パンくずリスト実装",
}),
).toEqual([
{ label: "ホーム", href: "/" },
{ label: "記事", href: "/blog" },
{ label: "パンくずリスト実装", href: "/blog/claude-code-breadcrumb-navigation" },
]);
});
});
UI側はTesting LibraryやPlaywrightで、現在ページに aria-current="page" があること、nav[aria-label] で取れること、JSON-LDが BreadcrumbList としてパースできること、スマホ幅で本文と重ならないことを見ます。ナビゲーション全体の表示確認まで自動化したいなら、Claude CodeでPlaywrightテストを書くの手順と組み合わせると楽です。
僕がやらかした失敗3つ
正直に書きます。最初に作ったパンくずは、表面はきれいでも中身がボロボロでした。
ひとつ目は、現在地を色だけで表していたこと。最後の項目を少し濃い色にして満足していました。でもこれ、目で見える人にしか伝わりません。スクリーンリーダーには全部「リンク」と平坦に聞こえるし、自動テストでも「どれが現在地か」を拾えない。aria-current="page" を一行足しただけで、テストも支援技術も一気に楽になりました。
ふたつ目は、画面とJSON-LDを別々の配列で作っていたこと。表示用の配列と構造化データ用の配列を別々に持っていて、カテゴリ名を変えたときに片方だけ直し忘れました。Search Consoleで構造化データのエラーが出て、はじめて気づいた。今は同じ items から両方を生成しています。直す場所が1か所になると、もう腐りません。
みっつ目は、JSON-LDに相対URLを入れていたこと。画面のリンクは /blog/example で動くので、つい構造化データにもそのまま入れていました。でも構造化データでは絶対URLが安全です。本番・ステージング・ローカルで siteUrl が混ざらないよう、new URL(href, siteUrl) で必ず絶対URLにそろえる。これでドメイン違いの事故が消えました。
始めるなら、ここから
いきなり全機能を盛らないでください。順番はいつも同じです。
itemsの形を1つに決める({ label, href }[])。表示もJSON-LDもここから作る。- 現在ページに
aria-current="page"、navにaria-label、区切りにaria-hidden。アクセシビリティの3点セットを先に入れる。 siteUrlから絶対URLを作ってBreadcrumbListを出す。画面と同じ配列で。- URLパスからの自動生成と、slugの人間向け整形を足す。
- 境界値テストとモバイルの省略CSSで仕上げる。
この順だと、各段階で「壊れたか」を確認しながら進めます。SEO面の全体設計が気になるならClaude CodeでSEOを最適化する、現在地表示以外のアクセシビリティ対応はアクセシビリティ実装も合わせて読むと、パンくず単体で終わらず回遊やインデックスにつながります。実装の腕試しとしては、無料チートシートで基礎を確認してから手を動かすのもおすすめです。
よくある質問
Q. 現在ページはリンクにすべき? それともテキスト?
A. どちらでも仕様上は許されます。リンクにしない(テキスト)なら自己リンクが消えてシンプル。リンクにするなら aria-current="page" を必ず付けて「今ここ」を明示します。迷ったらテキスト+ aria-current が無難です。
Q. 区切り記号は文字(/)で出していい?
A. 出していいですが、必ず aria-hidden="true" を付けて読み上げ対象から外します。CSSの ::before などで装飾として出す手もあります。どちらにせよ、意味を持たない飾りとして扱うのが原則です。
Q. JSON-LDのURLは絶対URLじゃないとダメ?
A. 相対でも一応読まれることはありますが、絶対URLが安全です。new URL(href, siteUrl) で本番ドメインの絶対URLにそろえれば、環境ごとのズレやドメイン誤りを防げます。
Q. 画面で中間階層を省略したら、JSON-LDからも消すべき? A. 消さないでください。省略はあくまで見た目の都合です。構造化データには全階層を残します。逆に、画面にない階層をJSON-LDにだけ足すのも避けます。両者は一致させるのが原則です。
Q. 多言語サイトではラベルをどう切り替える?
A. ラベル辞書をロケールごとに用意して、現在ロケールに応じて渡します。/en/ 配下なら英語ラベル、/ja/ 配下なら日本語ラベル、というふうに。URLとラベルの言語をそろえないと、英語ページに日本語が残る事故が起きます。
実際に試した結果
このコードは、Reactコンポーネント・URL生成ユーティリティ・Vitestの境界テストに切り分けて手元で確認しました。いちばん効いたのは、やっぱり同じ items から画面とJSON-LDを作る設計です。最初に別配列で組んだときはカテゴリ名の変更漏れが起きましたが、共通化したらレビューの観点が「items が正しいか」の一点に集約されました。
そして公開前に、aria-current があるか、@type が BreadcrumbList か、@id が本番ドメインの絶対URLか、画面とJSON-LDの階層が一致しているか——この4つをRich Results Testと実機で見ます。小さな部品ほど、目視ではなく機械で確かめる門番を1つ置く。それだけで、パンくずは「飾り」から「サイト構造を人にも検索エンジンにも伝える実装」に変わります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。