Skip to content

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

KeyAction
Tab / Shift + Tabmoves 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.

src/components/library/Breadcrumbs.tsx
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.