Breadcrumb
Navigation trail showing current page location within a hierarchy.
Breadcrumb shows where the user is in a hierarchical structure — Home → Products → Design System → Button. Each crumb (except the last) is a link back up the tree; the last is plain text marked as the current page.
For sibling navigation between sections, use TabBar. For flat top-level nav, use TopNavigation or SidebarNavigation.
Use it for
- Page headers in apps with hierarchical navigation
- Settings page section position
- Documentation site current-location markers
- Any place users benefit from "where am I" + "how do I go up"
Import
import { Breadcrumb } from '@brikdesigns/bds';Variants
Slash separator
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Design System' },
]}
/>Chevron separator
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Settings', href: '/settings' },
{ label: 'Profile' },
]}
separator="chevron"
/>Deep nesting
For 5+ level hierarchies, consider truncating with an ellipsis or root-only fallback — Breadcrumb itself doesn't auto-truncate; the consumer trims items before passing.
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: '…', href: '/clients' },
{ label: 'Acme Co', href: '/clients/acme' },
{ label: 'Engagements', href: '/clients/acme/engagements' },
{ label: 'Brand Refresh' },
]}
/>Switcher
Pass two or more options (plus a switchLabel) to render a caret after the trail. It opens a menu of sibling records — another service in the same line, another project on the same client — so users jump between them without navigating back to an index page. Fewer than two options renders no caret and the trail stays a plain breadcrumb. The trailing crumb keeps aria-current="page"; the caret is a separate aria-haspopup="menu" control alongside it.
onNavigate routes the selection client-side; without it BDS falls back to a full-page navigation (window.location.href).
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Services', href: '/services' },
{ label: 'Brand strategy' },
]}
options={[
{ label: 'Brand strategy', href: '/services/brand-strategy', current: true },
{ label: 'Brand identity', href: '/services/brand-identity' },
{ label: 'Brand guidelines', href: '/services/brand-guidelines' },
]}
switchLabel="Switch service"
onNavigate={(href) => router.push(href)}
/>Per-crumb switcher
The switcher above is scoped to the trailing crumb. Any crumb — including a MIDDLE one — can carry its own, independent switcher by passing switcher on that item instead: e.g. the "client" crumb on an admin project page switching between clients, while a separate trailing switcher (or none) handles project-to-project. It renders inline right after that crumb's label/link, before the separator to the next crumb, using the same caret + menu, the same two-or-more-options floor, and the same BreadcrumbSwitchOption shape as the trailing switcher. A trail can have neither, either, or both — each switcher keeps its own open/close state, so one never affects the other.
switcher.switchLabel is required (not optional, unlike the top-level switchLabel) — a trail can carry more than one per-crumb switcher, so each needs its own distinguishing accessible name rather than a shared generic fallback.
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Clients', href: '/clients' },
{
label: 'Acme Holdings',
href: '/clients/acme-holdings',
switcher: {
options: [
{ label: 'Acme Holdings', href: '/clients/acme-holdings', current: true },
{ label: 'Beacon Group', href: '/clients/beacon-group' },
{ label: 'Summit Partners', href: '/clients/summit-partners' },
],
switchLabel: 'Switch client',
onNavigate: (href) => router.push(href),
},
},
{ label: 'Projects', href: '/clients/acme-holdings/projects' },
{ label: 'Brand Refresh' },
]}
/>Search, filters and scope label
A long switcher menu — every client in a practice, say — can be narrowed in three ways. They work on the trailing options and on a per-crumb switcher alike.
- Search. A labelled search box appears once the option count is greater than
searchThreshold(default8;0always shows it). It matches the optionlabel, case-insensitively, and clears when the menu closes. - Filters. Pass
filtersto renderFilterToggles, off by default. Turning one on keeps only options whosefacets[facet] === value; thecurrentoption always stays visible. Filtering is client-side — BDS fetches nothing. - Scope label.
scopeLabelnames the list, rendered above it.
Options can carry a hint — secondary text under the label — which is display only and not searched. Search takes focus when the menu opens; Escape closes the menu and returns focus to the caret.
<Breadcrumb
items={[
{ label: 'Home', href: '/' },
{ label: 'Acme Dental', href: '/clients/acme-dental' },
{ label: 'Listings' },
]}
options={clients.map((c) => ({
label: c.name,
href: `/clients/${c.slug}/listings`,
current: c.slug === 'acme-dental',
hint: c.industry,
facets: { industry: c.industrySlug },
}))}
switchLabel="Switch client"
scopeLabel="Clients with Online Business Listings"
filters={[
{ id: 'same-industry', label: 'Same industry', facet: 'industry', value: 'dental' },
]}
/>Service-line cascade
When a breadcrumb sits inside a [data-service-line='X'] subtree, the current-page label and separators rebind canonical --text-secondary and --text-muted to the matching --text-service-{name} value (brand, marketing, information, product, and service → back-office).
The component itself stays scope-blind — only the cascade block in Breadcrumb.css knows about service lines. Consumers set data-service-line on any ancestor (page wrapper, hero card, sidebar column) and the breadcrumb inside picks up the hue. The legacy data-audience attribute still resolves during the deprecation window (#788).
<div data-service-line="marketing">
<Breadcrumb items={[
{ label: 'Services', href: '/services' },
{ label: 'Website Design' },
]} />
</div>When not to use
- Don't use Breadcrumb when there's no hierarchy. Single-level apps don't benefit — drop the trail entirely.
- Don't use Breadcrumb as primary navigation. It's a contextual aid, not a nav bar. Use TopNavigation / SidebarNavigation for primary nav.
- Don't make the last item a link. It represents the current page; clicking it would reload to itself.
Accessibility
- Renders a
<nav aria-label="Breadcrumb">element. - The last item carries
aria-current="page"and renders as plain text — not a link. - Earlier items render as
<a>whenhrefis set. - Separators are decorative (
aria-hidden). - Switcher search is labelled ("Search options"), each filter toggle is a button with
aria-pressedand its label as its name, and an empty result is announced through a status region.
API
| Prop | Type | Default |
|---|---|---|
items | BreadcrumbItem[] (required) | — |
separator | 'slash' | 'chevron' | 'slash' |
linkComponent | BdsLinkComponent | — |
options | BreadcrumbSwitchOption[] | — (two or more render the switcher caret) |
switchLabel | string | — (required when options is passed) |
onNavigate | (href: string, option: BreadcrumbSwitchOption) => void | undefined (window.location.href = href) |
filters | BreadcrumbSwitchFilter[] | — (each renders a FilterToggle, off by default) |
scopeLabel | string | — (rendered above the list) |
searchThreshold | number | 8 (search shows when the option count is greater; 0 always shows it) |
Plus standard <nav> HTML attributes.
BreadcrumbItem
interface BreadcrumbItem {
label: ReactNode;
href?: string;
switcher?: {
options: BreadcrumbSwitchOption[];
switchLabel: string;
onNavigate?: (href: string, option: BreadcrumbSwitchOption) => void;
filters?: BreadcrumbSwitchFilter[];
scopeLabel?: string;
searchThreshold?: number;
};
}BreadcrumbSwitchOption
interface BreadcrumbSwitchOption {
label: string;
href: string;
/** The record currently being viewed — highlighted and non-navigating. */
current?: boolean;
/** Secondary text under the label. Display only — not searched. */
hint?: string;
/** Data a filter matches against, keyed by facet name. Never rendered. */
facets?: Record<string, string>;
}BreadcrumbSwitchFilter
interface BreadcrumbSwitchFilter {
id: string;
/** Toggle label and accessible name. */
label: string;
/** Key into each option's `facets`. */
facet: string;
/** Facet value an option must equal while the filter is on. */
value: string;
}Related
- TabBar — sibling-section navigation
- TopNavigation — top-level primary nav
- SidebarNavigation — vertical primary nav
- Storybook playground