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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
- Type size12px · text-caption
Deliberately small. Breadcrumbs are context, not content — they should be findable but never compete with the page title below them.
- 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.
- 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.
- Separator13px chevron, aria-hidden
Hidden from assistive tech. Without that, a screen reader announces "greater than" between every level.
- Collapse threshold4 items
Keeps the first and the last two. The middle is the least useful part of a path, and collapsing beats wrapping.
- Gap4px around the separator
Tight, so the whole trail reads as one string. Wide gaps make each crumb look like an independent link.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-fg-muted | — | Ancestor links |
| --ds-fg | — | Current page |
| --ds-fg-disabled | — | Separators |
| --ds-layer-hover | — | Ellipsis button hover |
Spacing
| Token | Value | Used for |
|---|---|---|
| gap | Between crumb and separator |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs | Focus ring on a crumb |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-caption | Every crumb |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Type | Min width | Max width | When to use |
|---|---|---|---|---|---|---|
| Default | 20px | 4px | 12px | — | 100% | Page headers. One line, always. |
| Per-crumb max | — | — | — | — | 16ch | Truncate long names with an ellipsis and a title attribute. |
| Collapse at | — | — | — | 4 items | — | Above four levels, collapse the middle rather than wrapping. |
| Mobile | — | — | — | — | 100vw − 48px | Show the parent and the current page only, or a single back link. |
Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Moves through each ancestor link. The current page is not focusable. |
| Enter | Navigates 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.
| Attribute | Applied to | Notes |
|---|---|---|
| nav[aria-label="Breadcrumb"] | The container | Makes it a landmark, so screen-reader users can jump to it or skip it. |
| <ol> / <li> | The trail | An ordered list, because the order is the hierarchy. A row of spans conveys nothing. |
| aria-current="page" | The last crumb | The value is "page", not "true". This is what identifies the current location. |
| aria-hidden="true" | Separators | Otherwise a screen reader announces "greater than" between every level. |
| aria-label | The ellipsis button | "Show all breadcrumb levels", not a bare ellipsis. |
| title | Truncated crumbs | Gives the full name on hover when the visible label is clipped. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | Crumb[] | — | { label, href?, onClick?, icon? }. The last item should have no href. |
| maxItems | number | 4 | Above this, the middle collapses to an expandable ellipsis. |
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.