Navigation
Breadcrumb
Shows where the current page sits in a hierarchy, with a link to each level above it.
- Built on
<nav><ol>aria-currentCSS generated content with alt text- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.4 kB
- Dependencies
- React
Code
import { Breadcrumb } from "@/components/equip/breadcrumb";
export default function Demo() {
return (
<Breadcrumb
style={{ width: 216 }}
items={[
{ label: "Acme", href: "#acme" },
{ label: "larder-storefront", href: "#larder-storefront" },
{ label: "Deployments" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav style="width:216px" aria-label="Breadcrumb" class="eq-breadcrumb">
<ol class="eq-breadcrumb-list">
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#acme" title="Acme">Acme</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#larder-storefront" title="larder-storefront">larder-storefront</a>
</li>
<li class="eq-breadcrumb-item">
<span class="eq-breadcrumb-text" title="Deployments" aria-current="page">Deployments</span>
</li>
</ol>
</nav>Examples
File path
The last crumb keeps its href here, so it stays a link marked as the current page.
Code
import { Breadcrumb } from "@/components/equip/breadcrumb";
export default function Example() {
return (
<Breadcrumb
aria-label="File path"
items={[
{ label: "equip", href: "#equip" },
{ label: "registry", href: "#registry" },
{ label: "breadcrumb", href: "#registry-breadcrumb" },
{ label: "breadcrumb.css", href: "#breadcrumb-css" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav aria-label="File path" class="eq-breadcrumb">
<ol class="eq-breadcrumb-list">
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#equip" title="equip">equip</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#registry" title="registry">registry</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#registry-breadcrumb" title="breadcrumb">breadcrumb</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#breadcrumb-css" title="breadcrumb.css" aria-current="page">breadcrumb.css</a>
</li>
</ol>
</nav>Long middle crumbs
Middle crumbs are capped at 16 characters and shrink first when space runs out; the full name stays in the link text and its tooltip.
Code
import { Breadcrumb } from "@/components/equip/breadcrumb";
export default function Example() {
return (
<Breadcrumb
style={{ maxWidth: "min(320px, 100%)" }}
items={[
{ label: "Billing", href: "#billing" },
{ label: "Harbour Traders International Ltd", href: "#larder" },
{ label: "Invoices 2026 financial year", href: "#invoices" },
{ label: "INV-2041" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav style="max-width:min(320px, 100%)" aria-label="Breadcrumb" class="eq-breadcrumb">
<ol class="eq-breadcrumb-list">
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#billing" title="Billing">Billing</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#larder" title="Harbour Traders International Ltd">Harbour Traders International Ltd</a>
</li>
<li class="eq-breadcrumb-item">
<a class="eq-breadcrumb-link" href="#invoices" title="Invoices 2026 financial year">Invoices 2026 financial year</a>
</li>
<li class="eq-breadcrumb-item">
<span class="eq-breadcrumb-text" title="INV-2041" aria-current="page">INV-2041</span>
</li>
</ol>
</nav>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
items | { label: ReactNode; href?: string }[] | none | From the root down to the current page. The last item gets aria-current="page" and may leave out href. |
aria-label | string | "Breadcrumb" | Names the landmark. Change it when a page has more than one trail. |
...rest | ComponentProps<"nav"> | none | Every native nav attribute. |
Anatomy
| root | .eq-breadcrumb | A nav landmark. |
|---|---|---|
| list | .eq-breadcrumb-list | An ordered list, so the depth is announced. |
| item | .eq-breadcrumb-item | Draws the separator before itself. |
| link | .eq-breadcrumb-link | |
| text | .eq-breadcrumb-text | Used instead of a link when an item has no href. |
Keyboard
| Enter | Follows the focused link. |
|---|
Notes
- Renders on the server with no client JavaScript.
- The separators are CSS with empty alt text, so screen readers hear only the crumbs.
- Truncation is visual only; the accessible name of a cut-short crumb is still its full text.
- The last item is marked with aria-current="page", as a link if you give it an href and as plain text if not.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/breadcrumb.tsx45 lines
import type { ComponentProps, ReactNode } from "react";
import "./breadcrumb.css";
export type BreadcrumbEntry = {
label: ReactNode;
/** Where the crumb leads. The last item may leave it out. */
href?: string;
};
export type BreadcrumbProps = ComponentProps<"nav"> & {
/** From the root down to the current page. The last item is marked as the current page. */
items: BreadcrumbEntry[];
};
export function Breadcrumb({ items, className, "aria-label": label = "Breadcrumb", ...rest }: BreadcrumbProps) {
return (
<nav {...rest} aria-label={label} className={["eq-breadcrumb", className].filter(Boolean).join(" ")}>
<ol className="eq-breadcrumb-list">
{items.map((item, i) => {
const current = i === items.length - 1;
// Middle crumbs may be cut short; keep the full text on hover.
const title = typeof item.label === "string" ? item.label : undefined;
return (
<li key={i} className="eq-breadcrumb-item">
{item.href !== undefined ? (
<a
className="eq-breadcrumb-link"
href={item.href}
title={title}
aria-current={current ? "page" : undefined}
>
{item.label}
</a>
) : (
<span className="eq-breadcrumb-text" title={title} aria-current={current ? "page" : undefined}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}components/equip/breadcrumb.css85 lines
@layer equip {
.eq-breadcrumb {
min-width: 0;
max-width: 100%;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-breadcrumb-list {
display: flex;
align-items: center;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
/* Crumbs shrink before they wrap. The first stays whole; the middle ones go first. */
.eq-breadcrumb-item {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
color: var(--eq-ink-2);
}
.eq-breadcrumb-item:first-child {
flex-shrink: 0;
}
/* A huge shrink factor makes the middle crumbs reach their floor before the last one gives
anything. The floor covers the separator and its margins plus a few characters. */
.eq-breadcrumb-item:not(:first-child, :last-child) {
flex-shrink: 1000;
min-width: calc(2 * var(--eq-space-2) + 6ch);
}
.eq-breadcrumb-item:not(:first-child, :last-child) > :is(.eq-breadcrumb-link, .eq-breadcrumb-text) {
max-inline-size: 16ch;
}
/* The separator is drawn by CSS and given empty alt text, so it is never read out. */
.eq-breadcrumb-item + .eq-breadcrumb-item::before {
content: "/";
content: "/" / "";
flex: none;
margin-inline: var(--eq-space-2);
color: var(--eq-ink-3);
}
.eq-breadcrumb-link,
.eq-breadcrumb-text {
display: block;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.eq-breadcrumb-link {
min-height: 24px;
line-height: 24px;
border-radius: var(--eq-radius-s);
color: inherit;
text-decoration: none;
transition: color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-breadcrumb-link:hover {
color: var(--eq-ink);
text-decoration: underline;
text-underline-offset: 3px;
}
}
.eq-breadcrumb-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-breadcrumb [aria-current="page"] {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
}