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.
Separators
Section titled “Separators”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.
The current page
Section titled “The current page”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.
Overflow
Section titled “Overflow”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.
Styling contract
Section titled “Styling contract”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. |
API reference
Section titled “API reference”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.
Accessibility
Section titled “Accessibility”- 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-uniqueviolation, 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-blockis what keeps that ring from being clipped by the scroll container. - Contrast. Resting crumbs are
--_wayra-color-mutedonbase-100: 5.64:1 in light and 6.15:1 in dark. The current page isbase-contentonbase-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 and don’t
Section titled “Do and don’t”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.