Breadcrumbs
Navigation landmark · aria-current, CSS separators
An ordered trail of ancestor links inside a named nav landmark, with the current page marked by aria-current and left unlinked. The pattern where typing the separator into the markup quietly makes every level noisier to listen to.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift + Tab | moves through the ancestor links; the current page is not a link and is skipped, because there is nowhere to go |
Screen reader
- The landmark is announced by its label — “Breadcrumb navigation” — which is why the aria-label matters on a page that also has a main nav, and why a second trail on the same page needs a different name.
- The list announces its length, so the user knows how deep the trail is before moving through it.
- The separators are CSS ::after content and are never announced; a “/” typed into the markup would be read aloud between every item.
- The last item is announced as the current page thanks to aria-current=“page”.
ARIA notes
- nav needs aria-label (or aria-labelledby) whenever the page carries more than one navigation landmark — and the name must be unique among them, which is why the label here is a prop and not a constant.
- aria-current=“page” belongs on the item representing the page you are on — not on an ancestor, and not on every item.
- An <ol>, not a <ul>: the order of a trail is meaningful.
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
import { Link } from 'react-router-dom'
// Accessible breadcrumb trail — the pattern this site already runs in its own
// Layout, extracted so the library shows working code rather than a lookalike.
//
// Four decisions carry the accessibility here, and each is easy to get wrong:
//
// 1. `<nav aria-label="Breadcrumb">` — a landmark needs a NAME when a page
// has more than one nav (this site has the main nav too). Without the
// label, a screen-reader user hears "navigation" twice and cannot tell
// which is which.
// 2. `<ol>`, not `<ul>` — a trail is ordered. The list also tells assistive
// technology how many levels there are before the user starts moving.
// 3. Separators are drawn by CSS (`::after`), never typed into the markup.
// A literal "/" between items is announced — "Home slash Countries slash
// Germany" — and the noise scales with depth. Decoration belongs in the
// stylesheet, where screen readers do not go.
// 4. The current page is NOT a link and carries `aria-current="page"`. Two
// things at once: nobody needs a link to where they already are, and
// `aria-current` is what announces "this is your position" rather than
// leaving the last item indistinguishable from its ancestors.
//
// `label` is a prop, not a constant, because uniqueness is a property of the
// PAGE, not of the component. This very page proves it: the site's own layout
// already renders a "Breadcrumb" landmark above, so a second one with the
// default name would leave two landmarks a screen-reader user cannot tell
// apart — axe's `landmark-unique`, which is decision 1 failing one level up.
// Hard-coding the name would have made that unfixable without editing the
// component.
export type Crumb = { name: string; path: string }
export function Breadcrumbs({
trail,
current,
label = 'Breadcrumb',
}: {
trail: Crumb[]
current: string
label?: string
}) {
return (
<nav aria-label={label} className="text-sm text-on-surface-variant">
<ol className="flex flex-wrap gap-1">
{trail.map((c) => (
<li key={c.path} className="after:mx-1 after:content-['/']">
<Link className="underline-offset-2 hover:text-on-surface hover:underline" to={c.path}>
{c.name}
</Link>
</li>
))}
{/* Current page: plain text + aria-current, deliberately not a link. */}
<li aria-current="page" className="text-on-surface">
{current}
</li>
</ol>
</nav>
)
}
Accessibility pitfalls
AvoidWriting the separator as text: <li>Home</li> / <li>Countries</li>.
DoDraw it with CSS ::after — decoration must not reach the accessibility tree.
AvoidLinking the current page to itself and leaving it unmarked.
DoRender it as plain text carrying aria-current=“page”.
AvoidTwo nav landmarks sharing one name — a hard-coded aria-label="Breadcrumb" beside the site's own trail.
DoName each landmark distinctly; axe's landmark-unique catches the collision, and without it a screen-reader user cannot tell the two apart.