Design breadcrumbs
The trail of links that says where a page sits in a big hierarchy, like a GitHub file path or a Drive folder path. Drawing a few links is easy; the real work is deriving the trail from routes and data, naming dynamic segments without layout shift, correct landmark and aria-current markup, collapsing long trails on a phone, and matching structured data for search.
The question
Design the breadcrumbs component for our design system. It is used across a large app: a file browser where folders nest 15 deep, a product catalog where one product can live in two categories, and an admin console with routes like /orgs/:orgId/projects/:projectId/settings.
Walk me through where the trail comes from, how you name segments like abc123 that are only ids, the markup, what happens on a 360 pixel phone when the trail is 9 levels deep, and what you tell search engines.
Real-life examples: GitHub file path breadcrumbs with Copy path, Google Drive folder path, Amazon category trail, AWS Management Console, MUI, Ant Design and Carbon Breadcrumbs
Requirements: what a breadcrumb is for
A breadcrumb is a short row of links near the top of a page that shows where that page sits in the site's hierarchy: Home, then Electronics, then Laptops, then the laptop you are looking at. Each link takes you up one or more levels. The name comes from the fairy tale where children drop crumbs to find their way home.
It looks like a five minute task: map an array to links with a slash between them. A senior answer treats the breadcrumb as a small view of the app's hierarchy, fed by the router and the data, and shared by every product team. This chapter settles what the trail means and what we must support, then names what makes it hard.
Three kinds of breadcrumb, and only one is right
First settle what the trail means. People use the word for three different things, and mixing them is the most common design mistake.
| Kind | What it shows | Example | Verdict |
|---|---|---|---|
| Location based | Where the page lives in the hierarchy, the same for every visitor. | Home / Electronics / Laptops / ThinkPad X1 | This is the component. It answers "where am I?" and "what is above me?". |
| History based | The pages this visitor opened, in order. | Home / Search / Laptops / Deals / ThinkPad X1 | Avoid. The Back button already does this, and the trail differs per visitor, so it can't be cached or indexed. |
| Attribute based | The filters that led to the current set. | Laptops / 14 inch / Under 1,000 dollars | Not breadcrumbs. These are filter chips with remove buttons, covered in the search results page guide. |
A location trail has two big properties. It is deterministic: the same URL gives the same trail, so the server can render it and a search engine can read it. And it is about the parent chain, not about time, so it stays correct when a visitor arrives from a search result deep in the site, which is exactly when they need it most.
Check your understanding · Exercise 1
A product manager asks for breadcrumbs that show "the last 5 pages the user visited" on every page. Which responses are sound? Select all that apply.
Questions to ask first
| Question | Answer we assume | What it changes |
|---|---|---|
| Which hierarchies? | Files and folders (up to 15 deep), a product catalog (4 levels), an admin console (5 route levels). | The trail can't come from routes alone: folder depth is in the data, not the URL. |
| Do URLs contain names or ids? | Ids: /drive/folders/abc123, /orgs/42/projects/7. | Every id needs a label, which means a fetch, a loading state and a cache. |
| Can one item have two parents? | Yes. A product sits in Laptops and in Back to School. | We need one canonical path, or the trail and the search data disagree. |
| Server rendered and indexed? | The catalog and docs are. The file browser is behind login. | Structured data and server HTML for public pages only. |
| Devices and languages? | 55 percent phones at 360 to 414 pixels. Arabic and Hebrew too. | An overflow strategy, and a separator that mirrors right to left. |
| Who uses the component? | About 40 product teams, with Next.js and React Router apps. | A small API that takes the router's own Link and doesn't care where items come from. |
Functional requirements
- Show the ancestors of the current page as links, from the root down, and the current page as the last item.
- Build the trail from the route config, from an item's ancestor chain in the API, or from both.
- Name id segments with real labels, with a stable placeholder while they load.
- Fit any trail into the space it has: collapse the middle into a menu, shorten long labels, or show only a link to the parent.
- Emit BreadcrumbList structured data for public pages, matching the visible trail.
- Allow the router's Link component, a custom separator, and translated labels.
Non-functional requirements
- Accessible: WCAG 2.2 AA. A labelled nav landmark, the current page marked for screen readers, visible focus, targets at least 24 by 24 CSS pixels.
- Stable: no layout shift when labels arrive. The trail reserves its height from the first paint.
- Small: under 3 KB gzipped for the component; the overflow menu loads with it, not on demand, because it is tiny.
- Correct: the trail updates on every client side navigation and never shows the previous page's parents.
Out of scope
- The folder tree in a sidebar: that is a tree view, with its own keyboard model (see the tree view guide).
- Filter chips and category facets (see the search results page and Amazon guides).
- The main site menu and page level navigation such as tabs or pagination.
What makes a small trail hard
Five properties of the problem drive every later decision. Name them early in an interview: it shows you see past the slash separated list.
- The hierarchy is split between the URL and the data. The route /orgs/:orgId/projects/:projectId/settings has five segments, and the router knows every level. A Drive folder 12 levels deep has one URL segment, /folders/abc123; its 11 ancestors exist only in the database.
- Segments are ids, not names. The URL says 42 and 7; the trail must say Acme Inc and Billing API. Every id is a lookup that takes time, can fail, and can return an 80 character name.
- Width is unknown and small. A 9 level folder trail with short names (about 7 characters each) needs about 620 pixels at 14 pixel text, and long names double that. A 360 pixel phone gives you about 330. Something must give, and what gives decides whether the user can still reach the grandparent folder.
- It is a landmark that repeats on every page. Screen reader users hear it on every page and can jump to it. It must be named once, marked once, and not announce slashes as "slash, slash, slash".
- It is shared and public. Forty teams use it, with two routers. Public pages send the same trail to Google as structured data, and Google expects that markup to match what the page shows.
Notice what is not on the list: rendering speed. A trail has at most about 15 links. The hard parts are correctness (the right trail, the right names), fit (on any width) and meaning (for screen readers and crawlers). The chapters follow that order.
Think first
Before reading on: for the page /drive/folders/abc123, which parts of the trail could the router know, and which parts must come from the API?
The router knows the static prefix (the app root "My Drive") and the id abc123, but not the folder's name or its parents.
The API knows the name and the whole parent chain. So nested data gives a mostly data driven trail, while the admin console's fixed levels give a mostly route driven one.
Deriving the trail: routes, data, or both
The component draws a list. The real design question is who builds the list. Start with the naive answer and break it.
The naive approach: split the URL
Take location.pathname, split it on slashes, and make one crumb per segment: /orgs/42/projects/7/settings becomes orgs, 42, projects, 7, settings. It takes 6 lines and it fails in four ways at once.
- Ids as labels. The user sees 42 and 7, not Acme Inc and Billing API.
- Dead links. The segment orgs makes a link to /orgs, which may not be a page at all, so it returns a 404.
- Missing levels. /drive/folders/abc123 gives three crumbs, but the folder is 12 levels deep. The 11 ancestor folders are not in the URL.
- Wrong grouping. /docs/2024/release-notes might be one page with a date in its path, not three levels.
Decision Where does the trail come from?
What we pick: Build the trail from both, outside the component. The route tree gives the static frame and only real pages; the data gives deep chains and names. The design system component takes a plain array of items and stays ignorant of routers and APIs. A small useTrail hook per router does the merging.
// routes: crumbs live next to paths
{ path: "orgs/:orgId", loader: orgLoader,
handle: { crumb: (d: Org) => ({ label: d.name }) },
children: [{ path: "projects/:projectId", loader: projectLoader,
handle: { crumb: (d: Project) => ({ label: d.name }) } }] }
// hook: from root to leaf, keep matches that declare a crumb
export function useRouteTrail(): Crumb[] {
return useMatches()
.filter((m) => m.handle?.crumb)
.map((m) => ({ href: m.pathname, ...m.handle.crumb(m.loaderData) }));
}
The same idea works in Next.js. useSelectedLayoutSegments is a client hook, so a small client component inside a layout calls it to read the active segments below that layout. A server layout or page can fetch the item and its ancestors before sending HTML, so the trail arrives already named. Either way, the label for org 42 comes from the loader that already fetched org 42 for the page.
That is the key move: the crumb reuses the page's data, so it costs no extra request most of the time.
Naming dynamic segments
Sometimes the label is not in the page's data. A deep link to /orgs/42/projects/7/settings loads the settings form, which knows the project but maybe not the org's display name. Now each unnamed id needs a lookup. Three rules keep it cheap and calm.
- One batched request. Ask for every missing label at once (GET /labels?ids=org:42,project:7), not one request per crumb. Two to five ids in about 80 ms instead of five round trips.
- A cache keyed by type and id. Labels change rarely, so keep them in the server state cache for 5 minutes and reuse them across pages. Seed it from every list you load: a project list already has 50 names.
- A stable placeholder. While the label loads, show a skeleton bar of a fixed width (about 80 pixels) inside a non-link item, never the raw id. The bar has the same height as text, so nothing moves when the name arrives.
GET /labels?ids= covers every unknown id, and a failure only costs a generic label.If the lookup fails, show a generic fallback ("Project") as a working link, since the href is known from the route. The trail must never block the page or throw: it is secondary navigation. If the item was deleted or the user lost access, the page itself shows the error; the crumb just falls back.
Items with two parents
A ThinkPad sits in Electronics / Laptops and in Deals / Back to School. A Drive file can be shared into a folder the viewer doesn't own. The trail shows one path, so which one?
Decision Which path does a multi parent item show?
What we pick: Use one canonical path, owned by the data (a primaryCategoryId on the product); Nielsen Norman Group gives the same advice for visitors who arrive from outside. The visible trail and the JSON-LD must agree. Google's breadcrumb docs do allow several BreadcrumbList trails for one page, but their example also shows each trail on the page, so mark up a second path only if the page shows it too.
No JSON-LD: a logged in page, nothing for a crawler.Check your understanding · Exercise 2
A Drive folder page lives at /drive/folders/def456 and the folder is 7 levels deep. Which approaches give a correct trail? Select all that apply.
Markup and accessibility: a landmark, a list, and one current page
A breadcrumb has no keyboard interaction of its own: it is links in a row, and Tab moves through them. That makes the markup the whole accessibility story. The WAI-ARIA Authoring Practices Guide (APG) breadcrumb pattern asks for three things: a navigation landmark, a label on that landmark, and aria-current="page" on the link to the current page.
<nav aria-label="Breadcrumb" class="crumbs">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/electronics">Electronics</a></li>
<li><a href="/electronics/laptops">Laptops</a></li>
<li><a href="/p/thinkpad-x1" aria-current="page">ThinkPad X1</a></li>
</ol>
</nav>
| Piece | Why it is there | What a screen reader user gets |
|---|---|---|
| nav element | Makes a navigation landmark. | They can jump to it with a landmark shortcut (D in NVDA, R in JAWS) or the VoiceOver rotor. |
| aria-label="Breadcrumb" | A page often has 2 or 3 nav landmarks: main menu, breadcrumb, footer. | "Breadcrumb, navigation", so it is told apart from the main menu. |
| ol, not div | The order means something: each item is inside the one before. | "List, 4 items", so they know the depth before they start. |
| aria-current="page" | Marks which item is this page. | "ThinkPad X1, current page, link". |
aria-current give a screen reader user three facts the eye gets from layout. The slashes add nothing, so they stay out of the accessibility tree.Is the current page a link?
Decision How is the last item rendered?
What we pick: Default to plain text with aria-current="page": the APG allows it (aria-current is optional on a non-link) and it avoids a dead click. Offer a prop to render it as a link for products like GitHub, where the last path segment is often copied or opened in a new tab. Never leave it out in the default design.
Separators that screen readers don't read
If the slash is text in the markup, a screen reader says "Home, slash, Electronics, slash, Laptops". The nav label already says this is a trail, so the slashes are noise. Three ways to keep them out, from best to worst:
- A CSS border or background image on each li after the first, as the APG example does with a skewed border. Nothing to read at all.
- An inline SVG chevron with aria-hidden="true", when the design wants an icon. Easy to theme and to flip for right to left.
- CSS generated content with empty alternative text,
content: "/" / "". Plaincontent: "/"is not safe: generated content is often read aloud. Test the alternative text syntax with your screen readers.
.crumbs ol { display: flex; align-items: center; min-inline-size: 0;
list-style: none; margin: 0; padding: 0; } /* one line; collapse handles overflow */
.crumbs li { display: flex; align-items: center; min-inline-size: 0; }
.crumbs li + li::before {
content: "/" / ""; /* visible, not read */
margin-inline: var(--cf-space-200);
color: var(--cf-color-text-muted);
}
.crumbs a, .crumbs [aria-current] {
display: block; max-inline-size: 200px; /* one huge name can't take the row */
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
min-block-size: 24px; line-height: 24px; /* WCAG 2.5.8 minimum */
padding-inline: var(--cf-space-050);
}
.crumbs a:focus-visible {
outline: 2px solid var(--cf-color-focus-ring); outline-offset: 2px;
}
.crumbs svg.sep:dir(rtl) { transform: scaleX(-1); }
Target size, focus and right to left
Breadcrumb links are small text in a tight row, which is what WCAG 2.2 success criterion 2.5.8, Target Size (Minimum), is about. At level AA, a pointer target must be at least 24 by 24 CSS pixels, with a few exceptions. The spacing exception passes a smaller target if a 24 pixel circle centered on it touches no other target and no other small target's circle. The inline exception covers links inside a sentence, which a breadcrumb row is not.
A link in 14 pixel text is about 20 pixels tall. In a single row with separators between links, most links pass on spacing alone, but that depends on margins nobody re-checks when the design changes, and the W3C's guidance recommends meeting the minimum size anyway. So the CSS above gives every link a minimum height of 24 pixels and a little inline padding. On touch layouts, aim higher for the one or two items you keep (44 pixels is the enhanced level, 2.5.5, at AAA).
Focus must be visible on every link and on the overflow button: use :focus-visible with a 2 pixel outline that does not get clipped by an overflow: hidden parent (a common bug once you add truncation). For right to left languages, the list order flips by itself because flex follows the dir attribute: in Arabic the root sits on the right. A slash needs no change, but a chevron or arrow must be mirrored, or it points back toward the root. The rtl layouts lesson covers logical properties in general.
Check your understanding · Exercise 3
A team's breadcrumb renders: a div with class breadcrumb, then links separated by text nodes " > ", and the last item as a bold span. Which changes fix real problems? Select all that apply.
Overflow: fitting 9 levels into 330 pixels
Take a real trail: My Drive / Clients / Acme Inc / 2026 / Q3 / Contracts / Signed / Legal review / Final. Nine items and 61 characters, about 7 per label. At 14 pixel text a character is about 7.5 pixels wide, so the labels need about 460 pixels, plus 8 separators at about 20 pixels each: roughly 620 pixels. A 360 pixel phone, minus 16 pixels of padding each side, leaves 328, so only about half the trail fits, and real folder names are often longer.
Decision What gives when the trail doesn't fit?
What we pick: Combine them by width. Wide containers show everything. When it doesn't fit, collapse the middle into a menu, keeping the first item, the current page, and as many of the nearest ancestors as fit. Cap each label at about 200 pixels with an ellipsis, so one huge name can't push everything out. Below about 320 pixels of container width, switch to back to parent, but keep the full trail reachable from the menu. Never wrap on phones.
Tab to the … button, press Enter, then Tab through the hidden levels; Escape closes the list.
Measure the width, or use fixed rules?
The simple way is a rule: maxItems of 4 below 600 pixels, 8 above (MUI's maxItems defaults to 8, with itemsBeforeCollapse and itemsAfterCollapse both 1). Rules are predictable and need no JavaScript, but they are wrong both ways: four long names overflow a phone, and nine short ones would have fit. Because labels are data (folder names typed by users), only measuring is always right.
- Render every item once into a hidden measuring row (visibility: hidden, position: absolute, same font) and record the widths of each item, the separator and the ellipsis button. Re-measure only when labels or fonts change.
- Watch the container with a ResizeObserver. On each size change, compute how many items fit. A few additions, cheap even on every frame of a drag.
- Keep the first item and the last one. Add items back from the end (the nearest ancestors first, since they are the most useful), then from the start, until the next one would not fit.
export function fit(widths: number[], avail: number,
sep = 20, ellipsis = 32): { start: number; end: number } | null {
const n = widths.length;
const all = widths.reduce((s, w) => s + w, 0) + sep * (n - 1);
if (all <= avail || n < 3) return null; // fits, or nothing to hide
let used = widths[0] + widths[n - 1] + ellipsis + sep * 2;
let start = 1, end = n - 1; // hidden range is [start, end)
while (end - 1 > start) { // add ancestors from the end first
const w = widths[end - 1] + sep;
if (used + w > avail) break;
used += w; end--;
}
while (start < end - 1) { // then from the start
const w = widths[start] + sep;
if (used + w > avail) break;
used += w; start++;
}
return { start, end };
}
Server rendering has no width to measure. Send the server HTML with a conservative rule (first item, menu, last two, labels capped) that fits most phones, then let the client measure and correct it. Showing more or fewer items on the same line causes no layout shift, because the height doesn't change.
The collapsed menu
MUI's ellipsis button expands the whole trail inline (its label defaults to "Show path"). That is fine on desktop, but on a phone it brings back the wrapping we avoided, so a popup list suits narrow screens better.
Build it as a disclosure: a real button that shows and hides a region, with aria-expanded telling screen readers whether it is open. Give it an accessible name like "Show 6 more levels", and let it open the hidden ancestors as a list of links, in trail order. It is navigation, so plain links in a list are the right semantics. The menu role, with its arrow key model, is for application commands (the dropdown select guide covers menu buttons in depth).
- Enter or Space on the button opens the list and moves focus to its first link. Tab moves through the links; Escape closes it and returns focus to the button.
- Clicking outside or tabbing out closes it.
- The links keep their tree order from top to bottom (Clients, Acme Inc, 2026...), so the list reads like the trail.
- In the DOM, the button sits in its own li, in the place of the hidden items, so the list still announces a sensible count.
aria-expanded that shows a list of ordinary links. Focus goes in when it opens and comes back when Escape closes it, so a keyboard user never loses their place.Long labels and the title attribute
A label cut with text-overflow: ellipsis keeps its full text in the DOM, so screen readers still hear "Legal review for the Acme master services agreement". The problem is sighted users, and the quick fix, a title attribute, is weak: it shows only on mouse hover after a delay, never on keyboard focus, and not at all on touch. Use a small tooltip that opens on hover and on focus instead (the tooltip guide covers it), and only when the label is actually cut, which you know from the measurement.
Check your understanding · Exercise 4
Your trail has 9 items and the container is 328 pixels wide. Which behaviors are sound? Select all that apply.
Telling search engines: BreadcrumbList structured data
For public pages (the catalog, the docs), the trail is also a signal to search engines about where the page sits. You describe it with structured data: a small JSON block in the page, using the schema.org BreadcrumbList type. Google's breadcrumb docs accept JSON-LD, RDFa and Microdata; JSON-LD is the easiest to generate because it is separate from the visible markup.
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Electronics",
"item": "https://shop.example.com/electronics" },
{ "@type": "ListItem", "position": 2, "name": "Laptops",
"item": "https://shop.example.com/electronics/laptops" },
{ "@type": "ListItem", "position": 3, "name": "ThinkPad X1" }
]
}
- What Google requires. At least two ListItems, each with name, position and item (the URL); only the last may leave out item. Google doesn't need a ListItem for the home page, so starting at Electronics is fine even though the visible trail starts at Home.
- One source. Generate the JSON-LD from the same crumb array that renders the nav, on the server. Two hand written copies drift apart within a month.
- Absolute URLs in item, and the canonical ones, so the multi parent product always reports its primary category.
- Real labels only. Never emit a placeholder or an id. If a label didn't resolve on the server, leave the JSON-LD out of that response rather than send "Project".
- Escape it. Names are user data. Serialize with JSON.stringify and replace the less than sign with its unicode escape, or a folder named with a closing script tag breaks out of the block: a real XSS path.
- Logged in pages get none. The Drive trail is private; there is nothing for a crawler to see.
What does Google show? On desktop results, the visible URL above the title can show the domain followed by the breadcrumb trail. On January 23, 2025, Google began rolling out a change that removes breadcrumbs from mobile results, which now show only the domain. Google said sites need to do nothing, since it still supports breadcrumb markup for desktop results.
So structured data is still worth a few lines, but don't sell it as a ranking lever: it describes the page, it doesn't rank it. The Rich Results Test and the Search Console breadcrumb report tell you whether Google parsed it.
Check your understanding · Exercise 5
Which statements about breadcrumb structured data are true in 2026? Select all that apply.
Architecture, data model and component API
Put the pieces together. The rule from the derivation chapter shapes everything: the design system component draws, and app code derives. That boundary lets 40 teams with two routers share one component.
| Piece | Lives in | Job |
|---|---|---|
| Route crumb config | Each app's route files | Declares a static label, or a function of params and loader data, for each route that is a real page. |
| useTrail hook | A small adapter per router (Next.js, React Router) | Collects matched route crumbs, splices in the item's ancestor chain, asks the label cache for missing names, returns Crumb[]. |
| Label cache | The app's server state cache (TanStack Query or the framework cache) | Maps type and id to a name, batches misses into one request, keeps names for 5 minutes, seeded by lists. |
| Breadcrumbs component | The design system package | Renders nav, ol, links and separators; measures and collapses; renders the overflow disclosure; emits nothing about routing. |
| BreadcrumbJsonLd | The design system package, server only | Turns the same Crumb[] into an escaped JSON-LD script tag for public pages. |
Why not fetch labels inside the component? The page already has most of them, and a second fetch path means two caches with two answers for the same name. The component stays a pure function of its props plus its own width.
Data model
// design system: what the component renders
export type Crumb = {
key: string; // stable: route id or item id, not the index
label: string | null; // null = still loading: render a skeleton
href?: string; // absent on the current page
icon?: "home" | "folder";
};
// app side: an item that knows its parents (from the API)
type FolderWithPath = {
id: string; name: string;
ancestors: { id: string; name: string }[]; // root first
};
// app side: the label cache key
type LabelKey = `${"org" | "project" | "folder"}:${string}`;
| State | Where it lives | Why there |
|---|---|---|
| Current location | The URL | The trail must survive reload, sharing and Back. The URL is the source of truth. |
| Ancestor chain | Server state cache, with the item | It comes back with the folder or product. Moving a folder invalidates it. |
| Labels for ids | Server state cache, key type:id | Shared across pages, deduplicated, expired after a few minutes. |
| Measured widths, fit result | Component state | Purely visual, local to one instance and one width. |
| Menu open or closed | Component state | Local, reset on navigation. |
Crumb[], so they can't disagree.One correctness trap sits in the cache: renames and moves. Renaming Acme Inc to Acme Corp must update the label entry org:42 and every cached chain that includes it, or the trail shows the old name until the cache expires. Moving a folder changes the chains of everything under it, so invalidate those chains rather than patch them. The google drive guide covers moves and the folder tree in depth.
Check your understanding · Exercise 6
Why does the design system component take a plain array of crumbs instead of reading the router? Select all that apply.
The component API
The big libraries split two ways. MUI and Carbon take children: you write each link inside the component (Carbon marks the last one with isCurrentPage). Ant Design moved to an items array in version 5.3, with itemRender to draw each item. Which suits a shared component with measured overflow?
Decision Items array or children?
What we pick: Use an items array. Overflow and structured data both need the component to know the whole trail as data, and the array is exactly what useTrail produces. Accept the router's Link through a linkAs prop so client side navigation works without the design system importing any router.
type BreadcrumbsProps = {
items: Crumb[];
linkAs?: React.ElementType; // Next Link, React Router Link, or "a"
maxItems?: number; // hard cap even when wide; default 8
itemsBeforeCollapse?: number; // minimum kept; default 1 (the root)
itemsAfterCollapse?: number; // "rules" mode; default 2 (parent, current)
collapse?: "measure" | "rules"; // default "measure"
compactBelow?: number; // px; back to parent below this, default 320
currentAsLink?: boolean; // default false
separator?: "slash" | "chevron"; // tokens, not any node
labels?: { nav: string; showMore: (n: number) => string; back: (p: string) => string };
};
<Breadcrumbs items={useTrail()} linkAs={Link}
labels={{ nav: t("crumbs.nav"), showMore: (n) => t("crumbs.more", { n }),
back: (p) => t("crumbs.back", { parent: p }) }} />
Why linkAs and not an onNavigate callback? A real anchor with an href keeps Cmd or Ctrl click, middle click, "copy link" and prefetching on hover, which a callback on a button would lose. The router's Link renders that anchor and adds client side navigation on a plain click. The component passes it href, the children and its own class, and nothing else.
- Defaults that are correct. With no props except items, you get the nav, the list, aria-current, measured collapse and a 24 pixel target. Accessibility can't be opted out of by forgetting a prop.
- Separator as a token, not any node. A free node invites text separators that screen readers read aloud; tokens stay aria-hidden and mirror for right to left.
- Every string is a prop with a translated default. The landmark label, "Show 6 more levels" and "Back to Legal review" all need plural rules and word order per language, so they are functions of a count or a name, not fixed strings to concatenate.
- Stable keys. Crumb.key is a route id or item id. Using the index as key makes React reuse the wrong DOM node when a level is inserted, which can move focus to the wrong link.
Check your understanding · Exercise 7
A team asks for separator to accept any React node so they can use " >> " text. What is the best response? Select all that apply.
Correctness and performance: no shifts, no stale trails
A trail is tiny, so performance means two things: it must not move the page (Cumulative Layout Shift, CLS), and it must never be wrong for even one frame after a navigation.
No layout shift
The breadcrumb sits above the h1, so anything that changes its height pushes the whole page down. Google's guidance is a CLS of 0.1 or less at the 75th percentile. If a late 24 pixel row pushes most of an 800 pixel phone screen down, that one shift scores about 0.03 (24 divided by 800): a third of the budget for one row of links.
- One line, always. No wrapping means the height is fixed at the line height plus padding, known before any label loads.
- Fixed width skeletons. A loading label is an 80 pixel bar of text height, not an empty span that grows.
- Server rendered trail. Next.js server layouts or React Router loaders put the real names in the HTML, so there is often nothing to load at all.
- Collapse first, adjust on the same line. The server sends the conservative layout; the client's changes stay on one line, so nothing below moves.
The stale trail bug
A common bug: a Next.js layout renders the breadcrumb and keeps its state across navigations, because layouts don't remount. The user goes from folder Contracts to folder Invoices. The URL changes at once, but the new folder's ancestors are still loading, so for 300 ms the trail shows Invoices' URL with Contracts' parents. Worse, if the Contracts request finishes late, it can overwrite the new trail.
- Key the trail data by the current item id, never "the last response". The server state cache does this for free: the query key includes the folder id.
- Derive the static part from the current matches on every render; don't copy it into state.
- While the new chain loads, show the new static frame and skeletons, not the old chain. An old name next to a new URL is worse than a placeholder.
- Abort or ignore responses for ids that are no longer current (the request races lesson covers the pattern).
Think first
The trail's last item says "Q3 Report", the h1 says "Q3 report (draft)", and the browser tab says "Reports". Does it matter?
Yes. Screen reader users hear all three: the title when the page loads, the trail in the landmark, the h1 when they jump to the heading. Three names make them wonder if they are on the right page.
Derive all three from the same item name. The trail's last item and the h1 should match exactly; the document title can add the site name, like "Q3 report (draft) - Drive".
Long file paths, GitHub style
A code browser's trail is a file path: repo / src / components / system-design / extras / GuideTabs.tsx. Developers want two things there that a catalog doesn't need. First, every directory is a link, and the path is often long, so measured collapse matters even on desktop. Second, they want the path as text, which is why GitHub puts a Copy path button next to its file path.
Add it as an optional action outside the ol: a real button whose click calls navigator.clipboard.writeText with the joined path, then shows "Copied" and announces it through a polite live region. The clipboard API works only in a secure context (HTTPS), and some browsers also require a user gesture, which the click provides.
Check your understanding · Exercise 8
After moving from folder A to folder B, the trail briefly shows A's parents with B's name. Which fixes address the cause? Select all that apply.
Running it in the interview
Breadcrumbs is a 30 minute question, and the trap is finishing the markup in 5 minutes and then having nothing to say. Spend the time on the parts that show judgment.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 4 | Scope: which hierarchies, ids or names in URLs, multi parent items, SEO, phones. | "Location based, not history. Back already does history." |
| 4 to 10 | Derivation: route crumbs plus the data's ancestor chain, label cache, canonical path. | The architecture boxes: routes, useTrail, cache, component. |
| 10 to 16 | Markup: nav with a label, ol, aria-current, separators out of the accessibility tree. | Write the short HTML. It is fast and shows precision. |
| 16 to 24 | Overflow: options, measured collapse, the disclosure menu, back to parent on tiny widths. | Sketch the phone wireframe with the ellipsis button. |
| 24 to 30 | API, JSON-LD from the same array, layout shift, stale trails. | The props type, and one sentence on each risk. |
Say your trade-offs out loud. "I'll measure instead of using maxItems because labels are user data" earns more than the measurement code itself. Expect these follow-ups:
Think first
Follow-up: "Your collapsed menu hides 6 levels. How does a screen reader user know they exist?"
The ellipsis is a real button in its own list item, named "Show 6 more levels", with aria-expanded="false". It is in the Tab order between the root and the parent.
The list announces its real item count including the button, and opening it moves focus to the first hidden link. Escape returns focus to the button.
Think first
Follow-up: "A user opens a shared link to a folder whose parent they can't see. What does the trail show?"
Only what they may see. The ancestors API returns the chain cut at the first folder they lack access to, and the client shows that cut point as the root, often labelled like Drive's "Shared with me".
Never leak a hidden parent's name through the label cache. Labels are fetched with the viewer's permissions, like everything else.
Check your understanding · Exercise 9
Which moves make a breadcrumbs answer sound senior? Select all that apply.
Summary
- A breadcrumb shows location in a hierarchy, not history and not filters. Skip it on flat sites and in linear flows.
- Derive the trail from route crumbs plus the item's ancestor chain from the API, outside the component. The component takes a plain array.
- Name ids from the page's own data or one batched, cached label request; show a fixed width skeleton, never the raw id.
- Markup: nav with a translated aria-label, an ordered list, aria-current="page" on the last item, separators kept out of the accessibility tree, 24 pixel targets.
- On narrow widths, collapse the middle into a real disclosure button, decided by measurement, and fall back to back to parent on the smallest containers.
- Generate BreadcrumbList JSON-LD from the same array, with canonical URLs and escaped names. Google shows it on desktop only since January 2025.
- Keep it one line to avoid layout shift, and key trail data by the current item so a navigation never shows stale parents.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA APG: Breadcrumb pattern The nav landmark, its label and aria-current, with a worked example.
- MDN: aria-current The values (page, step, location...) and when to use each.
- Understanding WCAG 2.2 SC 2.5.8: Target Size (Minimum) The 24 by 24 pixel rule and its spacing exception.
- Google Search Central: Breadcrumb structured data Required properties and the multiple trail example.
- Google Search Central blog: Simplifying the visible URL element on mobile search results January 23, 2025: breadcrumbs removed from mobile results, still shown on desktop.
- schema.org: BreadcrumbList The type behind the JSON-LD.
- React Router: Using handle Route handles and useMatches, with a breadcrumbs example.
- Next.js: useSelectedLayoutSegments The client hook that reads the active segments below a layout, with breadcrumbs as the use case.
- MUI: Breadcrumbs API maxItems, itemsBeforeCollapse, itemsAfterCollapse and expandText defaults.
- Nielsen Norman Group: Breadcrumbs, 11 design guidelines for desktop and mobile When to use them, and why the current page isn't a link.
Concepts behind it
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.