cf.completefrontend
My progress
SYSTEM DESIGN · #9

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.

KindWhat it showsExampleVerdict
Location basedWhere the page lives in the hierarchy, the same for every visitor.Home / Electronics / Laptops / ThinkPad X1This is the component. It answers "where am I?" and "what is above me?".
History basedThe pages this visitor opened, in order.Home / Search / Laptops / Deals / ThinkPad X1Avoid. The Back button already does this, and the trail differs per visitor, so it can't be cached or indexed.
Attribute basedThe filters that led to the current set.Laptops / 14 inch / Under 1,000 dollarsNot 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

QuestionAnswer we assumeWhat 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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".
  5. 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.

Wireframe of a folder page on a wide screen and on a phone. Wide: the trail My Drive, Clients, Acme, 2026, then Final as the current page, then the heading Final and the folder contents. Phone: the trail shows My Drive, an ellipsis button, and Final; the ellipsis is open and lists Clients, Acme and 2026 as links. Notes: the trail is a nav labelled Breadcrumb, the last item has aria-current page, the row is one line with a fixed height, and the ellipsis is a button that opens a list of links.drive.example/folders/f9My DriveClientsAcme2026Finalh1: Finalfolder contentsphoneMy Drive…FinalClientsAcme2026nav, aria-label Breadcrumblast item: aria-current pageone line, fixed height… is a buttonopens a listof links
The same trail at two widths. The wide one shows every level; the phone keeps the root and the current folder and moves the middle into a list behind one button, so the row stays one line tall.

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?

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?

From the route tree

Each route declares its crumb next to its path: a static label ("Settings") or a function of the route params and loaded data.

At render time, take the matched routes from the root down and keep the ones that declare a crumb. React Router exposes them through useMatches and each route's handle; Next.js has useSelectedLayoutSegments.

Pros

  • No extra request for static levels.
  • Only real pages become links, because only routes declare crumbs.
  • Works for any app with nested routes, like an admin console.

Cons

  • Can't see levels that are not in the URL, like 11 ancestor folders.
  • Dynamic segments still need a label lookup.

From the data

The API returns the current item with its ancestor chain: a folder with its parents up to the root, or a product with its category path.

The page passes that chain straight to the component.

Pros

  • Handles any depth, because the chain lives with the data.
  • Names come back with the item, so there is no separate label fetch.
  • The same chain feeds the structured data.

Cons

  • Every page type must expose an ancestors field.
  • Pages without an item (settings, lists) have no chain to give.

Both: route crumbs with a data slot

Routes provide the static frame: Home, Drive, Settings.

A route whose segment is an item id fills its slot with that item's ancestor chain from the data.

The component receives one merged, ordered list and never knows where each item came from.

Pros

  • Fits all three of our hierarchies with one model.
  • Teams describe crumbs where they describe routes.

Cons

  • Two sources to keep in order. The merge needs a test.

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.

tsx
// 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) }));
}
React Router (data or framework mode): each route declares its crumb in handle; useMatches returns the matches with pathname, loaderData and handle.

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.
Sequence for a deep link to a project settings page. The user opens the link. The Page gives its route matches to useTrail. useTrail asks the Label cache for org 42, which misses and sends one batched GET for the labels to the API. Meanwhile the user already sees the frame with a skeleton in place of the org name. The API replies 200 with the names, and the cache gives the user Acme Inc in the same space. On the failure path the API replies 500 and the user sees the fallback label Org as a working link.UserPageuseTrailLabel cacheAPIopen linkmatchesorg:42?GET labelsskeleton200 namesAcme Incor 500fallback: Org
The trail never waits for a name. The frame and the links paint first, with a fixed width skeleton; the name fills the same space, so nothing shifts. One batched 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.

State machine for one crumb label. It starts as id known, from the URL. On a cache hit it goes straight to named. On a cache miss it goes to loading, which shows an 80 pixel skeleton. Loading goes to named on a 200 reply, and to fallback on a 500 or a timeout; fallback shows a generic label that is still a link, and the next visit retries by going back to loading. A note says that a rename updates the cached name, so the named state shows the new name.id knownfrom the URLloading80 px skeletonnamedAcme IncfallbackOrg, still a linkmiss200cache hit500 or timeoutnext visitrename: updateorg:42 in cache
Every state has a fixed width and a working link except loading, which is not clickable for a moment. No state ever shows the raw id or blocks the page.

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?

The path the user came from

Pass the parent category in the URL or session, and build the trail from it.

Pros

  • Matches the user's last click.

Cons

  • The same product has many URLs or many trails: duplicate pages for search, and the trail becomes history based again.
  • A visitor from Google has no "came from", so you need a default anyway.

One canonical path

The catalog marks one primary category per product. The trail, the canonical URL and the structured data all use it.

Pros

  • One URL, one trail, one answer for crawlers.
  • Cacheable: the trail depends only on the item.

Cons

  • A user who came from Deals sees Laptops, though the Back button still returns them to Deals.

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.

URL
Built from
Moment
URL /drive/folders/def456, trail built from both, at first. The trail reads: My Drive / loading / loading. Notes: root from routes, chain from the folder API one response gives every ancestor/drive/folders/def456My Drive//root from routes, chain from the folder APIone response gives every ancestor
Red items are ids or dead links, hatched ones are loading skeletons of a fixed width, grey ones are fallbacks that still link. The last item is the current page.
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.

html
<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>
The structure every variant renders. Separators come from CSS, not from text in the list.
PieceWhy it is thereWhat a screen reader user gets
nav elementMakes 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 divThe 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".
Two columns for the same breadcrumb. What you see: Home, a slash, Laptops, a slash, and ThinkPad X1 as the current page; the slashes come from CSS. What a screen reader says: Breadcrumb, navigation; list, 3 items; Home, link; Laptops, link; ThinkPad X1, current page. Below, the bad version with text separators reads Home, slash, Laptops, slash, ThinkPad X1, and gives no landmark and no current page.what you seeHome/Laptops/ThinkPad X1slashes from CSS: not readlast item: aria-current pagewhat is readBreadcrumb, navigationlist, 3 itemsHome, linkLaptops, linkThinkPad X1, current pagetext separators: Home, slash, Laptops, slash, ThinkPad X1nav label, ol, aria-current: three facts, no noise
The landmark label, the list count and 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?

A link with aria-current="page"

Render it like the others, with aria-current="page" and a style that looks like plain text (no underline, normal text color).

Pros

  • Matches the APG example exactly.
  • Consistent: every item is the same element, focusable, with the same target size.

Cons

  • A link to the page you are on does nothing useful when clicked, and sighted users may try it.

Plain text, not a link

Render a span inside the last li, with aria-current="page" still set.

Pros

  • No pointless self link. Nielsen Norman Group advises against linking the current page.
  • Clearly different from the clickable ancestors.

Cons

  • Not focusable, so a keyboard user Tabbing through never lands on it; screen readers still read it in the list.

Leave it out

Show only the ancestors and rely on the h1 for the page name.

Pros

  • Shorter trail, which helps on phones.

Cons

  • The trail looks unfinished, and the user must look elsewhere to see where they are. Nielsen Norman Group recommends ending the trail with the current page.

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:

  1. 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.
  2. An inline SVG chevron with aria-hidden="true", when the design wants an icon. Easy to theme and to flip for right to left.
  3. CSS generated content with empty alternative text, content: "/" / "". Plain content: "/" is not safe: generated content is often read aloud. Test the alternative text syntax with your screen readers.
css
.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); }
Separator, label cap, target size and focus, written with the design system's tokens and with logical properties so right to left works for free.

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?

Wrap to more lines

Add flex-wrap: wrap to the row and let labels flow onto more lines.

Pros

  • Zero code. Every level stays visible and clickable.

Cons

  • A 9 level trail takes 2 or 3 lines on a phone, up to about 90 pixels of the first screen.
  • The height depends on width and labels, so the content below jumps when labels load or the phone rotates.

Truncate long labels

Give each item a max width (say 160 pixels) with text-overflow: ellipsis, and show the full name on hover and focus.

Pros

  • Keeps every level. Good when the problem is one long name, not many levels.

Cons

  • Nine levels at even 60 pixels each, plus separators, need about 700 pixels: twice the 328 we have.
  • "Legal rev..." and "Legal req..." become indistinguishable.

Collapse the middle into a menu

Keep the first item and the last one or two. Replace the rest with an ellipsis button that opens a list of the hidden ancestors as links.

Pros

  • One line at any depth. The root and the current context stay visible.
  • Every ancestor is still two clicks away.

Cons

  • Hidden levels need a click to see.
  • Needs a measured or rule based decision on how many to hide, and an accessible popup.

Back to parent only

On narrow screens show one link: a back arrow and the parent's name, like "Legal review".

Pros

  • Smallest footprint, very clear on phones, a big target.
  • It is what users expect from native apps, and Nielsen Norman Group suggests showing only the last level on mobile.

Cons

  • The user can't jump two levels up in one tap.
  • Loses the "where am I" overview, which is half the point.

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.

Trail
Strategy
Trail of 9 levels in a 392 pixel container with the auto strategy. Shown: My Drive, …, Legal review, Final. 3 of 9 levels on one line; 6 behind the menu.container: 392 pxMy Drive/…/Legal review/Finalone line, fixed height
3 of 9 levels on one line; 6 behind the menu.

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.

  1. 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.
  2. 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.
  3. 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.
ts
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 };
}
Choose which items stay visible, given measured widths. Pure, so it is easy to unit test.

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.
Sequence for the collapsed menu. The user presses Enter on the ellipsis button. The button sets expanded and shows the hidden list, and focus moves to its first link, Clients. The user tabs to Acme and presses Enter. The list asks the Router to navigate, and the user sees the new trail with the menu closed. On the other path the user presses Escape on the button, the button closes the list, and focus goes back to the button.User… buttonHidden listRouterEnterexpandedfocus: ClientsEnter on Acmenavigatenew trailor Escapeclosefocus back
The ellipsis is a disclosure: a button with 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.

json
{
  "@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" }
  ]
}
BreadcrumbList for the laptop page. position starts at 1; the last item may omit its URL, since it is the current page.
  • 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.

PieceLives inJob
Route crumb configEach app's route filesDeclares a static label, or a function of params and loader data, for each route that is a real page.
useTrail hookA 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 cacheThe 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 componentThe design system packageRenders nav, ol, links and separators; measures and collapses; renders the overflow disclosure; emits nothing about routing.
BreadcrumbJsonLdThe design system package, server onlyTurns the same Crumb[] into an escaped JSON-LD script tag for public pages.
Architecture. In the app code zone: Route config gives crumbs to useTrail; the Page loader gives the item's ancestors to useTrail; useTrail sends unknown ids to the Label cache, which batches misses to the API in the server zone. In the design system zone: useTrail passes the Crumb array to the Breadcrumbs component, which measures and collapses and uses no router and no fetch, and the same array to BreadcrumbJsonLd, which renders escaped JSON-LD with canonical URLs on the server.App codeRoute configcrumb per routeuseTrailmerge + namesLabel cachetype:idPage loaderitem + chainServerAPIGET labelsDesign systemBreadcrumbsmeasure, collapseBreadcrumbJsonLdserver onlyCrumb[]same arrayno router, no fetchescaped, canonical
The app derives, the design system draws. Everything router specific or data specific stays above the line; the component below it is a pure function of an array and its own width.

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

ts
// 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}`;
The only type the component knows, and the shapes the app side uses to build it.
StateWhere it livesWhy there
Current locationThe URLThe trail must survive reload, sharing and Back. The URL is the source of truth.
Ancestor chainServer state cache, with the itemIt comes back with the folder or product. Moving a folder invalidates it.
Labels for idsServer state cache, key type:idShared across pages, deduplicated, expired after a few minutes.
Measured widths, fit resultComponent statePurely visual, local to one instance and one width.
Menu open or closedComponent stateLocal, reset on navigation.
Data flow for a folder page. The URL gives its path to the Router, which gives matched route crumbs to useTrail, which gives the Crumb array to Breadcrumbs. useTrail sends ids to the Label cache and gets names back; the cache sends one batched GET to the API. The API returns the item with its ancestors into the Server state cache, which gives the chain to useTrail. useTrail also gives the same array to the JSON-LD block in the page head.URL/folders/f9RoutermatchesuseTrailmergeBreadcrumbsnav + olJSON-LDhead scriptServer stateitem + chainLabel cachetype:idAPIitem, labelspathcrumbsCrumb[]ids, namesGETitem + ancestorschainsame array
The URL is the only input that changes on navigation; everything else is derived from it and cached by key. The JSON-LD and the visible trail read the same 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?

Children composition

Teams write Breadcrumbs with a BreadcrumbItem per level, each wrapping their own Link.

Pros

  • Feels like HTML. Any custom content per item.
  • No new link abstraction.

Cons

  • To collapse or measure, the component must inspect and clone children, which is fragile in React.
  • The JSON-LD can't be generated from arbitrary children.

Items array with a link renderer

Teams pass items: Crumb[] and a renderLink function (or a linkAs component) that receives href, children and props.

Pros

  • The component knows every label and href, so it can measure, collapse, list hidden items in the menu, and feed JSON-LD.
  • One Crumb[] flows from useTrail to the UI and to the JSON-LD.

Cons

  • Custom content per item needs an extra slot (icon, badge).

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.

tsx
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 }) }} />
Props, with defaults chosen for a phone first design system.

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.
Before and after. Left, trail added late: the content was drawn at the top, then the trail appeared above it and pushed the page 24 pixels down, a layout shift. Right, space reserved: the trail row is there from the first paint, with a skeleton where a name is loading, so when the name arrives the content stays in place, a 0 pixel shift.trail added lateHomeDocscontentmoved 24 px downspace reservedHomecontentskeleton, then name: 0 px
Reserve the row from the first paint and give loading labels a fixed width. The name then fills space that already exists.

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?

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.

MinutesWhat to doWhat to draw or say
0 to 4Scope: which hierarchies, ids or names in URLs, multi parent items, SEO, phones."Location based, not history. Back already does history."
4 to 10Derivation: route crumbs plus the data's ancestor chain, label cache, canonical path.The architecture boxes: routes, useTrail, cache, component.
10 to 16Markup: 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 24Overflow: options, measured collapse, the disclosure menu, back to parent on tiny widths.Sketch the phone wireframe with the ellipsis button.
24 to 30API, 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?"

Think first

Follow-up: "A user opens a shared link to a folder whose parent they can't see. What does the trail show?"

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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next