Skip to content

Component

breadcrumb

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and breadcrumb.css. No scripts.

Source

Copy both files, or run npx nojsui add breadcrumb.

Source for breadcrumb
<!-- .sk-breadcrumb — an ordered trail with a current position.

     Two variants of the same structure:
       default          a navigation trail — <nav> + <ol>, aria-current="page"
       data-variant     a stepper — a progress indicator, aria-current="step"
         ="steps"

     They share a shape (an ordered sequence with one current item) but not a
     meaning, which is why the wrapper element and the aria-current value both
     change. The README says when to reach for which. -->
<nav class="sk-breadcrumb" aria-label="Breadcrumb">
  <ol class="sk-breadcrumb__list">
    <li class="sk-breadcrumb__item">
      <a class="sk-breadcrumb__link" href="#home">Home</a>
    </li>
    <li class="sk-breadcrumb__item">
      <a class="sk-breadcrumb__link" href="#components">Components</a>
    </li>
    <li class="sk-breadcrumb__item">
      <a class="sk-breadcrumb__link" href="#breadcrumb" aria-current="page">Breadcrumb</a>
    </li>
  </ol>
</nav>

<!-- The stepper. data-step and data-total drive the progress fill with typed
     attr(); without it the dots still mark the current step. -->
<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>
    <li class="sk-breadcrumb__item">
      <span class="sk-breadcrumb__step">Payment</span>
    </li>
    <li class="sk-breadcrumb__item">
      <span class="sk-breadcrumb__step">Confirm</span>
    </li>
  </ol>
</div>
/* .sk-breadcrumb — an ordered trail (navigation) or a stepper (progress).

   Both are plain lists. There is no interactive behaviour to implement: the
   trail is anchors, the stepper is text. Everything below is presentation, plus
   one computed progress fill.

   Specs:
   - typed attr() ........... https://drafts.csswg.org/css-values-5/#attr-notation
   - aria-current ........... https://w3c.github.io/aria/#aria-current
   - :dir() ................. https://drafts.csswg.org/selectors-4/#the-dir-pseudo */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. */
.sk-breadcrumb {
  --_link-fg: var(--sk-breadcrumb-link-fg, var(--sk-color-text-muted));
  --_link-fg-hover: var(--sk-breadcrumb-link-fg-hover, var(--sk-color-text));
  --_current-fg: var(--sk-breadcrumb-current-fg, var(--sk-color-text));
  --_separator-fg: var(--sk-breadcrumb-separator-color, var(--sk-color-text-subtle));
  --_underline: var(--sk-breadcrumb-underline-color, var(--sk-color-border-strong));
  --_accent: var(--sk-breadcrumb-accent, var(--sk-color-accent));
  --_track-bg: var(--sk-breadcrumb-track-bg, var(--sk-color-border));
  --_track-size: var(--sk-breadcrumb-track-size, var(--sk-space-2xs));
  --_step-fg: var(--sk-breadcrumb-step-fg, var(--sk-color-text-subtle));
  --_step-done-fg: var(--sk-breadcrumb-step-done-fg, var(--sk-color-text-muted));
}

.sk-breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sk-space-xs);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--sk-text-sm);
}

.sk-breadcrumb__item {
  display: flex;
  align-items: center;
  gap: var(--sk-space-xs);
}

/* The separator is decoration, so it is generated content rather than markup —
   nothing for a screen reader to read out between every crumb. */
.sk-breadcrumb__item + .sk-breadcrumb__item::before {
  content: "›";
  color: var(--_separator-fg);
}

/* A chevron points along the reading direction, so it has to turn around in a
   right-to-left document. :dir() is Baseline widely available. */
.sk-breadcrumb__item + .sk-breadcrumb__item:dir(rtl)::before {
  content: "‹";
}

.sk-breadcrumb__link {
  color: var(--_link-fg);
  text-decoration-color: var(--_underline);
  text-underline-offset: 0.2em;
}

.sk-breadcrumb__link:hover {
  color: var(--_link-fg-hover);
}

/* The current page is still a link — it is a real destination, and removing the
   href would take it out of the tab order for no reason. aria-current is what
   marks it, and the styling follows that attribute rather than a class, so the
   two cannot drift apart. */
.sk-breadcrumb__link[aria-current="page"] {
  color: var(--_current-fg);
  font-weight: var(--sk-weight-medium);
  text-decoration: none;
}

/* ---------------------------------------------------------------------------
   Stepper variant
--------------------------------------------------------------------------- */

.sk-breadcrumb[data-variant="steps"] .sk-breadcrumb__list {
  justify-content: space-between;
  gap: var(--sk-space-sm);
}

/* No separator chevrons in the stepper — the track does that job. */
.sk-breadcrumb[data-variant="steps"] .sk-breadcrumb__item + .sk-breadcrumb__item::before {
  content: none;
}

.sk-breadcrumb__track {
  position: relative;
  block-size: var(--_track-size);
  margin-block-end: var(--sk-space-xs);
  border-radius: var(--sk-radius-full);
  background: var(--_track-bg);
  overflow: hidden;
}

/* Without typed attr() there is no fill at all — see the @supports block. A
   fill that defaulted to full width would report every stepper as complete,
   which is worse than showing no bar. */
.sk-breadcrumb__fill {
  display: none;
  block-size: 100%;
  border-radius: inherit;
  background: var(--_accent);
}

.sk-breadcrumb__step {
  color: var(--_step-fg);
  font-size: var(--sk-text-xs);
}

.sk-breadcrumb__step[data-state="done"] {
  color: var(--_step-done-fg);
}

.sk-breadcrumb__step[data-state="current"] {
  color: var(--_accent);
  font-weight: var(--sk-weight-medium);
}

@supports (inline-size: attr(data-step type(<integer>), 0)) {
  .sk-breadcrumb[data-variant="steps"] {
    /* Read straight off the markup: no duplicated value in a style attribute,
       and no chance of the number and the bar disagreeing. */
    --_progress: calc(
      attr(data-step type(<integer>), 0) / attr(data-total type(<integer>), 1) * 100%
    );
  }

  .sk-breadcrumb__fill {
    display: block;
    inline-size: var(--_progress);
  }

  @media (prefers-reduced-motion: no-preference) {
    .sk-breadcrumb__fill {
      transition: inline-size var(--sk-motion-base) var(--sk-ease-out);
    }
  }
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Flexboxwidely291220929209
:dir()widely1201204916.41204916.4
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
attr()1 of its partslimited133133133

Two variants of one shape

VariantElementMarks current withUse 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).

PropertyDefaultControls
--sk-breadcrumb-link-fg--sk-color-text-mutedCrumb link text
--sk-breadcrumb-link-fg-hover--sk-color-textCrumb link text on hover
--sk-breadcrumb-current-fg--sk-color-textThe aria-current="page" crumb
--sk-breadcrumb-separator-color--sk-color-text-subtleThe chevron between crumbs
--sk-breadcrumb-underline-color--sk-color-border-strongCrumb link underline
--sk-breadcrumb-accent--sk-color-accentProgress fill and the current step
--sk-breadcrumb-track-bg--sk-color-borderUnfilled part of the stepper track
--sk-breadcrumb-track-size--sk-space-2xsTrack thickness
--sk-breadcrumb-step-fg--sk-color-text-subtleAn upcoming step
--sk-breadcrumb-step-done-fg--sk-color-text-mutedA 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.

KeyBehaviour
TabMoves through the trail’s links in order
EnterFollows 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-current values differ by variantpage for the trail, step for the stepper. They are not interchangeable.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
FlexboxwidelyItems stack vertically; the trail still reads in order.
:dir()widelyThe separator chevron points the wrong way in right-to-left documents.
Typed attr()limitedNo 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.