Skip to main content

Breadcrumbs

betav0.1.0

Hierarchical path to the current page: its ancestors, separated by a slash, the last entry marked as the page you are on. Renders the WAI-ARIA breadcrumb pattern — a named navigation landmark, an ordered list, separators hidden from assistive technology, aria-current="page" on the current entry. Entries are data (`items`), not children, and a linked entry keeps its destination in a prop: pass the host router's link component once through `linkComponent` and this component keeps the label, the truncation and the ARIA. Long labels ellipsize in CSS and expose the full string through `title`.

Variants

A full pathFour entries — the deepest path measured in either product, and every entry state at once: a category with no destination, two links (same colour as the category, distinguished by the underline), then the current page (semibold, primary, aria-current).
Hover and keyboard focusHover a link: the label brightens to primary, the underline stays. A surface hover is impossible here — the hover surface token measures 1.09:1 against the page in dark. Tab walks the links only, each getting the same brightening PLUS the focus ring.
A label longer than its rowThe row is deliberately narrow. Entries ellipsize in CSS, never at a character count, and carry the full label in `title`.
An adornment beside the pathAn info icon with its tooltip, placed after the current entry. Hover it, or Tab to it — it comes after the last link, which is what makes the tooltip keyboard-reachable. It sits outside the list, so a screen reader reads four steps, not five.
A single entryThe shallowest real path — 22 call sites across both products. No separator follows the last entry, so this one shows none.

Examples

<Breadcrumbs
  items={[
    { label: "Entities" },
    { label: "Sectors", href: "/sectors" },
    { label: "Energy", current: true },
  ]}
/>
// With a router: pass the link component ONCE, keep destinations in the data.
import { Link } from "react-router";

<Breadcrumbs
  linkComponent={Link}
  items={[
    { label: t("Simulations"), to: "/admin/simulations" },
    { label: exercise.name, to: `/admin/simulations/${exercise.id}` },
    { label: inject.title, current: true },
  ]}
/>
// An info icon beside the path, keyboard-reachable, never a path step.
<Breadcrumbs
  items={items}
  adornment={
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <IconButton
            icon={<Icon name="info" size={16} />}
            aria-label="About this page"
            priority="tertiary"
            size="sm"
          />
        </TooltipTrigger>
        <TooltipContent>Generated from the entity's own data.</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  }
/>
// Spacing belongs to the host, and so do its own DOM hooks.
<Breadcrumbs
  className="mb-2"
  id="page-breadcrumb"
  items={items}
  label={t("Breadcrumb")}
/>

Props

PropType
itemsBreadcrumbsItem[] — the path, root first. `{ label, href?, to?, current? }`. `label` is the FULL string (this truncates). `href` is for a plain anchor or a link component that reads href (Next); `to` is for a ROUTER link component — react-router replaces an href passed through it with the current location, so migrating a product's `link` field means `to`, not `href`. `current` renders text with aria-current, never a link. Empty renders nothing.
linkComponentReact.ElementType — element for an entry with a destination. Default `"a"`. Pass the router's link component for client-side navigation; it must forward `className` and the destination onto its own root.
adornmentReact.ReactNode — host content after the current entry, inside the landmark but OUTSIDE the list, so it is never announced as a step. For an info icon or a status marker. Focusable content here tabs after the last link, so a tooltip on it is keyboard-reachable. Use it instead of wrapping this component.
labelstring — accessible name of the navigation landmark. Default "Breadcrumb"; translate it in a localised host.
...nav propsEvery other prop lands on the `<nav>` — `className` (spacing is the host's), `id`, `data-*`, `ref`.

Accessibility

2.1 AAWCAG pass

1.3.1 — <nav aria-label> > <ol> > <li>, each separator in its own <li aria-hidden="true"> so the path's accessible name is the labels alone. The <ol> restates the list reset the library ships no preflight for. 2.4.8 / 4.1.2 — the current entry is a <span> with aria-current="page", never a link; a second one warns in dev. Neither product wrapper ships aria-current, which is the gap this closes. 2.1.1 / 2.4.3 — links only, in document order, walked with Tab, then the `adornment` if it holds anything focusable; separators and the current entry are not focusable. No arrow-key model: this is a list of links, not a composite widget. 2.4.7 — standard focus ring on every link, AND the label brightens to primary on :focus-visible. Two cues, neither replacing the other. 1.4.1 Use of Colour — MET, with no exemption. A linked and an unlinked ancestor paint the same token (--text-default-secondary), so colour distinguishes nothing here: the cue is the permanent underline on the linked state, a non-colour visual means available at rest, before any interaction. WCAG technique G183 and its 3:1 foreground-to-foreground floor do not apply — that technique exists only to tolerate links identified by colour alone, which this design no longer does. The hover/focus delta (secondary -> primary, underline unchanged) IS colour-only, and is supplemental rather than the affordance: the link is already identified at rest, and keyboard users additionally get the ring. The underline is load-bearing, so it is locked by two named guards — Breadcrumbs.test.tsx's ACCESSIBILITY GUARD block and Breadcrumbs.figma.json via check:figma-fidelity (retired guard scripts/lib/breadcrumbs-link-underline.test.ts, 2026-09-26). 1.4.3, measured on --bg-elevation-default in both modes (AGENTS.md requires the ratios here, not only in the report): ancestor, linked or not, #afb0b6 on #070d18 = 8.99:1 dark, #494a50 on #f2f2f3 = 7.89:1 light; hovered/focused link, current entry and separator #f2f2f3 on #070d18 = 17.38:1 dark, #18191b on #f2f2f3 = 15.72:1 light; focus ring #42caff = 10.30:1 dark, #0015a8 = 11.22:1 light against 3:1. On a layer-1 card the same four states measure 8.27 / 15.98 / 15.98 / 9.47 dark and 8.83 / 17.59 / 17.59 / 12.55 light — every state clears its threshold on both surfaces. 1.4.4 / 1.4.10 — entries ellipsize in CSS, the full label stays in the DOM and in `title`. The current entry is held at its width and capped at two fifths of the row, so the ancestors give up space first; measured as rendered width in a real engine by docs/e2e/breadcrumbs-narrow-track.spec.ts. The current entry is held at its width and capped at 32rem (512px), a container-independent unit, so the list needs no `grow` and the `adornment` stays beside the path rather than at the end of the track. ACCEPTED LIMIT: a fixed cap cannot adapt to a narrower track — on a 343px track the ancestors render at 61% with a 6-crumb path, and at 1% when a single 51-character current label alone exceeds the usable track. Unreachable in product (shell min-width 1400px, track 1304px, no flex parent across 163 call sites) — RFC §6.1. A host must not add its own tooltip on an entry: the products' per-entry MUI Tooltip is removed by the migration, not layered. No disabled state: a path entry is content, not a control. wcagStatus "pass" basis: axe reports 0 violations across the demo in both modes, the three pairs above are computed from theme.css in CI, and 39 component tests, three browser measurements plus one script guard cover the ARIA, the tab order, the link affordance and the prop-contract warnings — each proven to fail on a deliberately broken implementation before being trusted. Automated checks cover roughly half of WCAG; the human/screen-reader audit cadence still applies.