Skip to content

Breadcrumbs

The trail of pages above the one you are on. It rests muted, a crumb lifts onto a soft pill wash when you point at it, and the current page is the one crumb that is not a link. The separators are drawn by the stylesheet from a single attribute, so the component ships no JavaScript beyond wrapping each child in a list item.

  • Stable
  • React 19
  • Zero JS
  • CSS-only separators

A landmark, an ordered list, and a mark the stylesheet draws between the items.

Code
<Breadcrumbs>
<a href="/">Home</a>
<a href="/library">Library</a>
<span aria-current="page">Data</span>
</Breadcrumbs>
<nav class="wayra-breadcrumbs" aria-label="Usage trail" data-separator="arrow">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>

You write the crumbs; the component writes the structure. Each child is wrapped in its own <li> inside the <ol>, and the root is a <nav> already labelled Breadcrumb. There is nothing to configure for the common case.

A crumb behind a condition needs no special handling. null and false children are skipped, so a flag that is off leaves no empty item and no stray separator:

<Breadcrumbs>
<a href="/">Home</a>
{canEdit && <a href="/library/edit">Edit</a>}
<span aria-current="page">Data</span>
</Breadcrumbs>

The default label is written before the prop spread, so a single instance can replace it: <Breadcrumbs aria-label="Section trail">. That is not a nicety. Two navigation landmarks sharing a name is an accessibility failure, so a page with two trails has to name them apart.

Five marks, selected with one prop and drawn entirely by the stylesheet.

Code
<Breadcrumbs separator="arrow">…</Breadcrumbs>
<Breadcrumbs separator="slash">…</Breadcrumbs>
<Breadcrumbs separator="dash">…</Breadcrumbs>
<Breadcrumbs separator="bullet">…</Breadcrumbs>
<Breadcrumbs separator="pipe">…</Breadcrumbs>
<nav class="wayra-breadcrumbs" aria-label="Arrow separator" data-separator="arrow">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>
<nav class="wayra-breadcrumbs" aria-label="Slash separator" data-separator="slash">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>
<nav class="wayra-breadcrumbs" aria-label="Dash separator" data-separator="dash">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>
<nav class="wayra-breadcrumbs" aria-label="Bullet separator" data-separator="bullet">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>
<nav class="wayra-breadcrumbs" aria-label="Pipe separator" data-separator="pipe">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>

arrow is the default because it is not a character at all: it is two borders on an empty box, rotated 45°. That survives Windows high contrast, which strips backgrounds but keeps borders, and it needs no font to be present. It points at the next crumb, so under a dir="rtl" on the trail or any ancestor it points left — the attribute is what the browser floor reads, and where :dir() exists an LTR island inside an RTL page points right again.

The other four are glyphs, and a glyph in a pseudo-element would otherwise be read aloud between every pair of crumbs. Each one ships the alternative-text form of content:

.wayra-breadcrumbs[data-separator="slash"] li + li::before {
content: "/";
content: "/" / "";
}

The empty string after the slash is what the accessibility tree gets — nothing. The pair is progressive: engines below Chrome 77, Safari 17.4 or Firefox 128 drop the second declaration and keep the plain string above it, so the mark is always drawn. Those versions are all inside the browser floor, which is why the fallback is a plain string rather than a second mechanism.

Mark it with aria-current="page". The stylesheet keys on that attribute and never on position, so a trail is free to end on a link and a crumb in the middle is free to be the page you are on.

Code
<Breadcrumbs>
<a href="/">Home</a>
<span aria-current="page">Library</span>
<a href="/library/data">Data</a>
</Breadcrumbs>
<nav class="wayra-breadcrumbs" aria-label="Current at the end" data-separator="arrow">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>
<nav class="wayra-breadcrumbs" aria-label="Current mid-trail" data-separator="arrow">
<ol>
<li><a href="/">Home</a></li>
<li><span aria-current="page">Library</span></li>
<li><a href="/library/data">Data</a></li>
</ol>
</nav>

The current crumb takes the full base-content colour and font-weight: 500, keeps the cursor at default, and is left out of the hover wash. It is a <span>, not an anchor: a link to the page you are already on is a tab stop that does nothing.

Only a value that means “current” counts. aria-current="false" and an empty value are ARIA’s default, so a trail that writes the attribute on every crumb — aria-current={here ? "page" : false} — draws the others as ordinary crumbs, hover wash included. Every other token (page, step, location, true) is drawn as current.

A long trail scrolls rather than wrapping. The list holds its natural width and the landmark clamps at --wayra-breadcrumbs-max-width, which is none until you set it.

Six crumbs in a 20rem box. Drag or shift-scroll to see the rest.

Code
<Breadcrumbs style={{ '--wayra-breadcrumbs-max-width': '20rem' }}>
…
</Breadcrumbs>
<nav class="wayra-breadcrumbs" style="--wayra-breadcrumbs-max-width: 20rem" aria-label="Long trail" data-separator="arrow">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><a href="/library/data">Data</a></li>
<li><a href="/library/data/reports">Reports</a></li>
<li><a href="/library/data/reports/2026">2026</a></li>
<li><a href="/library/data/reports/2026/august">August</a></li>
</ol>
</nav>

There is no maxWidth prop on purpose: a trail is clamped by the layout around it, so the value belongs to an instance through style or to a theme through a rule — not to a prop that has to be threaded down every wrapper.

The root carries padding-block so the browser’s focus ring on a crumb has room inside the scroll container. Zeroing that padding puts the ring back under the clip. The inline edges still cut the ring while the trail is scrolled mid-way; that is the accepted cost of a scroll container.

The class plus data-separator is the public API, and the stylesheet is written against it. Every preview on this page is hand-authored HTML — no React anywhere:

<nav class="wayra-breadcrumbs" aria-label="Breadcrumb" data-separator="slash">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/library">Library</a></li>
<li><span aria-current="page">Data</span></li>
</ol>
</nav>

data-separator is required in hand-written markup, not optional. The shared rule for the mark sets its box and its opacity but ships no content; the content comes from the attribute, so markup without it draws no mark at all.

Two custom properties are yours to set:

Property Default What it does
--wayra-breadcrumbs-gap calc(var(--wayra-size-selector) * 2) Space on both sides of the separator.
--wayra-breadcrumbs-max-width none Where the trail starts scrolling instead of growing.

BreadcrumbsProps extends ComponentProps<"nav"> minus color, so id, style, ref, className, aria-label and every data-* land on the root landmark.

Prop Type Default Description
separator "arrow" | "slash" | "dash" | "bullet" | "pipe" "arrow" The mark drawn between two crumbs, emitted as data-separator.
children ReactNode — The crumbs. Required. One <li> each; null and false are skipped.
unstyled boolean false Drops the wayra-breadcrumbs class, keeps data-separator.

There is no color. A trail is chrome above the page rather than a report of state, so the eight semantic roles the rest of the library takes would be API nobody reaches for; React’s legacy color attribute is removed with it so it cannot be mistaken for that API. There is no size either — a breadcrumb takes the type scale of the page it labels. And there is no maxWidth, for the reason given under Overflow.

  • The landmark carries a name. aria-label="Breadcrumb" is the default, written before the spread so it can be replaced. Do not add the word “navigation” to it: the role is already announced, and the name would double it.
  • Two trails need two names. Two landmarks with the same role and the same name is an axe landmark-unique violation, not a style preference.
  • The list does the counting. An <ol> tells a screen reader “item 2 of 3” for free. That is the whole reason the markup is an ordered list rather than a row of links.
  • aria-current="page" is yours to set. The component never guesses which crumb is current, because position does not decide it.
  • The separators are silent. The four glyph marks ship content: "…" / "", so they are absent from the accessibility tree; the arrow is two borders and was never in it. Below Chrome 77 / Safari 17.4 / Firefox 128 the plain glyph is read, which is the documented cost of the fallback.
  • Focus is the browser’s. The crumbs are ordinary anchors and keep the user agent’s own focus ring — the library draws none here. The root’s padding-block is what keeps that ring from being clipped by the scroll container.
  • Contrast. Resting crumbs are --_wayra-color-muted on base-100: 5.64:1 in light and 6.15:1 in dark. The current page is base-content on base-100: 17.9:1 light, 17.7:1 dark. Both clear AA for body text.
  • No underline, by design. A trail is not a block of text, so WCAG 1.4.1 does not ask for one: the crumbs sit alone in their items with nothing to be confused with. The two non-colour cues are the pill wash under the pointer and the weight change on the current page.

Do

The last crumb is a span with aria-current="page". It is not a link, and the stylesheet stops drawing it as one.

Don't

Three links, one of which leads to the page you are already on: a tab stop that does nothing and no signal about where you are.

Do

A name that says something and does not repeat the role.

Don't

Announced as “breadcrumb navigation, navigation”. The role is already spoken; the label only has to distinguish this trail from another one.