Skip to content

Breadcrumbs

Location, not history. They answer "where am I in this hierarchy" and give a one-click route back up it — nothing more.

Also called Breadcrumb Trail, Path — in this system all of them are Breadcrumbs.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

At every depth

Two levels barely justifies the component. Four is the sweet spot. Six collapses the middle and keeps the row on one line.

2 levels

3 levels

6 levels

In a page header

The usual placement: above the page title, muted, on one line. It sets the context before the title names the thing.

Build 4021

Deployed 4 minutes ago · 42 seconds · 3 regions

Collapsing

Press the ellipsis to expand. Keeping the first and the last two is the pattern users recognise from file managers.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Production
Link
Production
Hover
Production
Focus
Build 4021
CurrentNot a link
›
Separator
…
Collapsed
Acme
With icon
A very long project name
Truncated

Anatomy

Every part, every measurement, and the reason it is that number.

Root, collapsed middle, the last two levels, and the current page. Six levels rendered on one line in about 340px.

  1. Type size12px · text-caption

    Deliberately small. Breadcrumbs are context, not content — they should be findable but never compete with the page title below them.

  2. Ancestor colour--ds-fg-muted

    Muted and underlined only on hover. A row of six blue underlined links is louder than the page it describes.

  3. Current page--ds-fg, 500 weight

    Full contrast, not a link, and marked aria-current="page". The contrast step is what makes the trail read as a position.

  4. Separator13px chevron, aria-hidden

    Hidden from assistive tech. Without that, a screen reader announces "greater than" between every level.

  5. Collapse threshold4 items

    Keeps the first and the last two. The middle is the least useful part of a path, and collapsing beats wrapping.

  6. Gap4px around the separator

    Tight, so the whole trail reads as one string. Wide gaps make each crumb look like an independent link.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-fg-muted—Ancestor links
--ds-fg—Current page
--ds-fg-disabled—Separators
--ds-layer-hover—Ellipsis button hover

Spacing

TokenValueUsed for
gapBetween crumb and separator

Radius

TokenValueUsed for
--radius-xsFocus ring on a crumb

Typography

TokenValueUsed for
--text-captionEvery crumb

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapTypeMin widthMax widthWhen to use
Default20px4px12px—100%Page headers. One line, always.
Per-crumb max————16chTruncate long names with an ellipsis and a title attribute.
Collapse at———4 items—Above four levels, collapse the middle rather than wrapping.
Mobile————100vw − 48pxShow the parent and the current page only, or a single back link.

Do

Make the last crumb the current page and not a linkIt is the anchor that makes the rest of the trail meaningful. A clickable current page reloads the page the user is already on, which is a small broken promise.
Collapse the middle, not the endThe root gives orientation and the last two give position. The levels in between are the ones users least need, so they are the ones to hide.
Acme Corporation › Production › api-gateway/w/8f21c/env/prod/svc/4021
Use the real names, not the route segments"Acme Corporation › Production › api-gateway" is a location. "/w/8f21c/env/prod/svc/4021" is a URL, and it means nothing to the person reading it.
Truncate individual crumbs, not the trailOne very long project name should shorten with an ellipsis and a title attribute. Dropping levels because one name is long loses structural information.

Don't

Dashboard › Search › Results › Settings › Build 4021
Do not show historyBreadcrumbs describe where the page is, not how the user got here. A history trail differs per visitor, cannot be shared, and answers a question nobody asked.
Workspaces › Acme Corporation › Production › api-gateway › Deployments › Build 4021
Do not wrap onto a second lineA wrapped trail reads as body copy and stops working as a location indicator. Collapse the middle instead — the row must stay on one line.
Do not use breadcrumbs for a flat structureIf every page is one level deep, the trail is always "Home › Page". That is a title with extra chrome, and it takes up space that the title deserves.
No sidebar, no top bar — just a trail
Do not make breadcrumbs the primary navigationThey only go up. A user who wants a sibling — a different project in the same workspace — has to climb and then descend. That is what a sidebar is for.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.4.4Link Purpose (In Context)A2.4.8LocationAAA4.1.2Name, Role, ValueA

Contrast

  • Ancestor links use --ds-fg-muted at 12px, which sits at 4.6:1 — the floor. Do not go lighter to make them recede further.
  • The separator is decorative and exempt, but it should still be visible enough to read as a separator rather than a rendering artefact.
  • Hover must change more than the cursor. Ours moves the label to full foreground contrast.

Keyboard

TabMoves through each ancestor link. The current page is not focusable.
EnterNavigates to the ancestor.
Enter (on ellipsis)Expands the collapsed middle in place.

Screen readers

  • Announced as "Breadcrumb, navigation, list of 4 items". The list structure is what gives the count and the position.
  • The current page announces as "Build 4021, current page" thanks to aria-current. Without it, the last item is just another list item.
  • Do not add "You are here" as visually hidden text. aria-current already says it, and duplicating it is verbose.

Focus & touch

  • Standard focus ring at 4px radius. Because the crumbs are small and close together, the 2px offset matters — a flush ring on 12px text is very hard to see.
  • Crumbs are 12px text and need padding to reach a 44px target on touch. On narrow screens, prefer a single "‹ Production" back link over a full trail.
AttributeApplied toNotes
nav[aria-label="Breadcrumb"]The containerMakes it a landmark, so screen-reader users can jump to it or skip it.
<ol> / <li>The trailAn ordered list, because the order is the hierarchy. A row of spans conveys nothing.
aria-current="page"The last crumbThe value is "page", not "true". This is what identifies the current location.
aria-hidden="true"SeparatorsOtherwise a screen reader announces "greater than" between every level.
aria-labelThe ellipsis button"Show all breadcrumb levels", not a bare ellipsis.
titleTruncated crumbsGives the full name on hover when the visible label is clipped.

Code

Example usage

tsx
1import { Breadcrumbs } from '@/ui/Navigation'23// Location, not history. Derived from the route, not from the visit.4<Breadcrumbs5  maxItems={4}6  items={[7    { label: workspace.name, href: '/w/' + workspace.id },8    { label: env.name,       href: '/w/' + workspace.id + '/' + env.id },9    { label: service.name,   href: serviceHref },10    { label: 'Build ' + build.number },      // no href = current page11  ]}12/>1314// Derive from the route so the trail can never disagree with the page15function useBreadcrumbs() {16  const { workspace, env, service, build } = useRouteData()17  return useMemo(18    () =>19      [20        workspace && { label: workspace.name, href: workspaceHref(workspace) },21        env && { label: env.name, href: envHref(env) },22        service && { label: service.name, href: serviceHref(service) },23        build && { label: 'Build ' + build.number },24      ].filter(Boolean),25    [workspace, env, service, build],26  )27}2829// Add structured data on public pages — search engines render it30<script type="application/ld+json">31  {JSON.stringify({32    '@context': 'https://schema.org',33    '@type': 'BreadcrumbList',34    itemListElement: items.map((it, i) => ({35      '@type': 'ListItem', position: i + 1, name: it.label, item: it.href,36    })),37  })}38</script>

Framework-free HTML

html
<nav aria-label="Breadcrumb">
  <ol class="ds-breadcrumbs">
    <li>
      <a href="/w/acme">Acme Corporation</a>
      <svg aria-hidden="true" class="ds-breadcrumbs__sep">…</svg>
    </li>
    <li>
      <a href="/w/acme/prod">Production</a>
      <svg aria-hidden="true" class="ds-breadcrumbs__sep">…</svg>
    </li>
    <li>
      <span aria-current="page">Build 4021</span>
    </li>
  </ol>
</nav>

CSS

css
.ds-breadcrumbs {
  display: flex;
  align-items: center;
  gap: 4px;
  min-inline-size: 0;                /* lets children truncate */
  list-style: none;
  font-size: 12px;
}

.ds-breadcrumbs li {
  display: flex;
  align-items: center;
  gap: 4px;
  min-inline-size: 0;
}

.ds-breadcrumbs a {
  color: var(--ds-fg-muted);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-inline-size: 16ch;             /* truncate the crumb, not the trail */
}
.ds-breadcrumbs a:hover { color: var(--ds-fg); }

.ds-breadcrumbs [aria-current='page'] {
  color: var(--ds-fg);
  font-weight: 500;
}

.ds-breadcrumbs__sep {
  flex-shrink: 0;
  color: var(--ds-fg-disabled);
}

/* Separators mirror automatically in RTL when drawn as a chevron */
[dir='rtl'] .ds-breadcrumbs__sep { transform: scaleX(-1); }

/* On narrow screens, a single back link beats a squeezed trail */
@media (max-width: 480px) {
  .ds-breadcrumbs li:not(:nth-last-child(2)):not(:last-child) { display: none; }
}

Component API

Breadcrumbs

PropTypeDefaultDescription
items*Crumb[]—{ label, href?, onClick?, icon? }. The last item should have no href.
maxItemsnumber4Above this, the middle collapses to an expandable ellipsis.

Notes

Professional tips

  • Derive the trail from the route, never from navigation history. A trail assembled from where the user has been is a different and much less useful component.
  • On mobile, replace the full trail with a single "‹ Parent" link. It is the same affordance in a tenth of the space.
  • If a crumb’s name can be very long — user-entered project names usually can — cap it at about 16 characters and put the full name in a title attribute.
  • Put breadcrumbs above the page title, never below it. They are context for the title, and context comes first.

Performance

  • Breadcrumbs are cheap, but resolving names for every ancestor can mean several requests. Include the ancestor names in the page payload rather than fetching them separately.
  • Prefetch the parent route on hover. Going up one level is by far the most common breadcrumb interaction.
  • Do not animate the collapse expansion. It is a rare, deliberate action and animation just delays it.

Common mistakes

  • Making the current page a link, so clicking it reloads the page the user is already on.
  • Forgetting aria-hidden on the separators, so a screen reader reads "greater than" between every level.
  • Using spans instead of an ordered list, which loses the count and the position for assistive tech.
  • Letting the trail wrap onto two lines instead of collapsing the middle.
  • Showing route segments instead of human names, which turns a location indicator into a URL.

Real-world recommendations

  • Breadcrumbs matter most for users who arrive deep from a link or a notification. Test the pattern by opening a deep URL in a fresh tab and asking whether you can tell where you are.
  • On public pages, breadcrumb structured data is rendered directly in search results and measurably improves click-through. It is a small change with a real return.
  • If users routinely need siblings rather than ancestors, add a switcher to the relevant crumb — a workspace crumb that opens a workspace picker is one of the highest-value navigation upgrades available.
  • Track clicks per crumb position. If nobody ever clicks the middle levels, collapsing them by default is clearly correct.