Component
breadcrumb
Demo
tokens.css,
base.css and breadcrumb.css. No scripts.
Source
Copy both files, or run npx nojsui add breadcrumb.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Flexbox | widely | 29 | 12 | 20 | 9 | 29 | 20 | 9 |
| :dir() | widely | 120 | 120 | 49 | 16.4 | 120 | 49 | 16.4 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| attr()1 of its parts | limited | 133 | 133 | — | — | 133 | — | — |
Two variants of one shape
| Variant | Element | Marks current with | Use when |
|---|---|---|---|
| Trail | <nav aria-label="Breadcrumb"> + <ol> | aria-current="page" | Showing where a page sits in a hierarchy |
| Stepper | <div role="group" aria-label="…"> | aria-current="step" | Showing progress through a sequence |
They share a structure — an ordered sequence with one current item — but not a
meaning, so the wrapper and the aria-current value both change. A stepper is
not navigation and should not be announced as a landmark.
<nav class="sk-breadcrumb" aria-label="Breadcrumb">
<ol class="sk-breadcrumb__list">
<li class="sk-breadcrumb__item"><a class="sk-breadcrumb__link" href="/">Home</a></li>
<li class="sk-breadcrumb__item">
<a class="sk-breadcrumb__link" href="/here" aria-current="page">Here</a>
</li>
</ol>
</nav>
<div class="sk-breadcrumb" data-variant="steps" data-step="2" data-total="4"
role="group" aria-label="Checkout progress">
<div class="sk-breadcrumb__track" aria-hidden="true">
<div class="sk-breadcrumb__fill"></div>
</div>
<ol class="sk-breadcrumb__list">
<li class="sk-breadcrumb__item"><span class="sk-breadcrumb__step" data-state="done">Cart</span></li>
<li class="sk-breadcrumb__item">
<span class="sk-breadcrumb__step" data-state="current" aria-current="step">Delivery</span>
</li>
</ol>
</div>
How it works
The progress bar reads the markup. data-step and data-total drive the
fill width through typed attr(), so the number and the bar cannot disagree —
there is no duplicated value in a style attribute to forget to update.
--_progress: calc(
attr(data-step type(<integer>), 0) / attr(data-total type(<integer>), 1) * 100%
);
The fallback shows no bar at all, on purpose. This is the interesting part.
Without typed attr() the custom property is invalid, inline-size falls back
to auto, and the fill renders at full width — a two-of-four stepper
reporting itself complete. Measured in Gecko before the guard went in.
A wrong answer is worse than no answer, so the fill is display: none outside
the @supports block and only turns on where the computation works. Verified:
Chromium and WebKit render 50% for data-step="2" data-total="4"; Gecko renders
no bar, and the step labels still mark the current one.
Separators are generated content, so there is nothing between crumbs for a
screen reader to read out. The chevron flips direction under :dir(rtl) —
verified › in LTR and ‹ in RTL across all three engines.
The current page stays a link. Removing the href would take it out of the
tab order for no benefit; aria-current is what marks it, and the styling keys
off that attribute rather than a class, so the two cannot drift apart.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. breadcrumb.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-breadcrumb-link-fg | --sk-color-text-muted | Crumb link text |
--sk-breadcrumb-link-fg-hover | --sk-color-text | Crumb link text on hover |
--sk-breadcrumb-current-fg | --sk-color-text | The aria-current="page" crumb |
--sk-breadcrumb-separator-color | --sk-color-text-subtle | The chevron between crumbs |
--sk-breadcrumb-underline-color | --sk-color-border-strong | Crumb link underline |
--sk-breadcrumb-accent | --sk-color-accent | Progress fill and the current step |
--sk-breadcrumb-track-bg | --sk-color-border | Unfilled part of the stepper track |
--sk-breadcrumb-track-size | --sk-space-2xs | Track thickness |
--sk-breadcrumb-step-fg | --sk-color-text-subtle | An upcoming step |
--sk-breadcrumb-step-done-fg | --sk-color-text-muted | A completed step |
.checkout { --sk-breadcrumb-track-size: 0.5rem; }
How far the fill runs comes from data-step and data-total on the markup,
read with typed attr() — it is the component’s data, not its theme, and
keeping it in one place is what stops the number and the bar disagreeing.
Keyboard contract
Plain anchors in the trail; plain text in the stepper. Nothing custom, and nothing to get wrong.
| Key | Behaviour |
|---|---|
Tab | Moves through the trail’s links in order |
Enter | Follows a link |
The stepper contains no interactive elements at all.
Verified manually in VoiceOver and NVDA: not yet — do this before the
component is marked done. The thing to check is that aria-current is announced
on the right item in both variants.
Accessibility notes
- The trail is a landmark; the stepper is not.
<nav>for the trail with a label,role="group"for the stepper. Marking a progress indicator as navigation would put it in the landmark list for no reason. - The track is
aria-hidden. It is a picture of the same information the step labels already carry. - Do not drop the current page’s link. It is a real destination.
aria-currentvalues differ by variant —pagefor the trail,stepfor the stepper. They are not interchangeable.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
| Flexbox | widely | Items stack vertically; the trail still reads in order. |
:dir() | widely | The separator chevron points the wrong way in right-to-left documents. |
Typed attr() | limited | No progress bar. Step labels still mark the current step — the fill is hidden rather than showing a wrong value. |
The support data lags here too. web-features 3.35.0 records
css.types.attr.type_function.integer as Chromium-only, but WebKit computes it
correctly — measured, a 2-of-4 stepper renders a 50% fill in WebKit. The badge
follows support.json per ADR 0004 rather than being hand-edited; this note
exists so the discrepancy is visible. It is the second component where the data
is behind the engines, after select.