Where the reader is, spelled out: Jobs › Acme import › Parts. Every crumb with an href navigates through the app's own router link, the current page and any segment without a page of its own read as plain text, and a name that has not loaded yet draws a same-height skeleton instead of an id (breadcrumbs.tsx:43-77). Two shipped shapes ride on presets: speedway's page trail and walmart's topbar crumb, so neither app keeps a private copy.
The canon behind it. method/design-language/08-navigation-and-shell.md §A7 (08-navigation-and-shell.md:32, a trail earns its row only two or more levels deep) and method/design-language/09-page-composition.md §A4 (09-page-composition.md:25, breadcrumbs sit first in the fixed composition order, shallow trails hidden).
When to reach for it#
Any page more than one level deep: a record inside a list, a doc inside a section, a settings tab inside an account. The trail earns its row only when it says something the title does not; a single crumb restates the page title, so hideSingle (on by default) renders nothing for it (owner ruling 2026-07-29, carried from speedway/app/components/ShellCrumbs.tsx:154-155).
Do not reach for it to build tabs, a section switcher, or a filter chip row. Crumbs describe where the reader is; they never change what a list shows.
Contract#
items: BreadcrumbItem[](required): each{ key?, label?, href?, pending? }.href: present on every crumb that navigates. Omit it on the current page, and on a segment with no page of its own (/admin/teamsalone is not a page), so it renders as text rather than a link that lands nowhere.pending: the name-bearing segment whose name a route loader has not returned yet; aSkeletonholds its place at crumb height.key: only whenlabelis not a string; falls back tohref, then the index.
presets("trail" | "topbar"): partial-prop bundles, explicit props win.trail:separator="chevron", every crumb quiet,size="xs",hideSingle. Speedway's.shell-crumbs(speedway/app/styles/app.css:259-277) and every playground page header.topbar:separator="slash",emphasizeCurrent,size="sm", never hidden. Walmart's topbarCrumb(walmart-mvp/frontend/src/App.tsx:141-156).
separator("chevron" | "slash", default chevron),emphasizeCurrent(default false),size("xs" | "sm", default xs),hideSingle(default true).linkAs: the app's router link (NextLink, react-routerLink), the sameNavLinkComponentButtonandAppShelltake, so crumbs prefetch and navigate client-side. Without it, a plain<a>.label: the nav's accessible name, default "Breadcrumb". The last text crumb carriesaria-current="page".className: the nav wrapper.
Deriving items from the path#
deriveCrumbs(pathname, rules) builds the items array from a pathname and rules the app declares once, so the walk itself is never hand-rolled (derive-crumbs.ts). Pure and router-free: pass location.pathname from any router.
labels: static segments and their labels; an unlisted segment shows exactly as it appears in the path and is never recased, so an id stays exact.noPage: segments whose crumb reads as text (nohref), same rule as thehrefbullet above.drop: segments that render no crumb and swallow the one segment after them (speedway hidesworkspacesand the workspace id together; hrefs still advance through both).dynamic: namers for dynamic values keyed by the segment before them (jobsnames the id in/jobs/{id}from what a route loader already knows). Return{ pending: true }while the name loads, ornullto fall back.hide: judge the whole split path;truerenders no trail (speedway's dashboard rule).
The last derived crumb never carries an href: the current page reads as text with aria-current, per the Banned rule below. Extra crumbs a path cannot express (speedway's ?tab= crumb) are appended by the caller after deriving.
A label that is a string never overflows its row. An id-shaped label (no spaces) longer than 24 characters loses its middle (a1b2c3d4-000...456789abc, both ends kept) and any other long label ends in an ellipsis at 32ch; either way the full value shows in a tooltip on hover. Short labels carry no tooltip.
Laws it enforces#
- Fit, then truncate, never clip: a long crumb truncates and carries the full value on hover.
- Machine values stay exact, human text stays human:
deriveCrumbsnever recases a raw segment.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
presets="trail" with linkAs above a PageTitle, in a flex flex-col gap-2 | The house page header | apps/playground/src/app/showcase-title.tsx, apps/playground/src/app/docs/doc-title.tsx | One header shape for every playground and docs page |
presets="trail" with a pending item | A trail whose record name is still loading | speedway's job and team crumbs (ShellCrumbs.tsx:97-104) | The skeleton keeps the row's height; the id never shows |
presets="topbar" with one or two text crumbs | Quiet group, slash, bold current page | walmart's topbar (App.tsx:141-156) | The topbar names the page; it is not a trail, so it is never hidden |
deriveCrumbs feeding presets="trail" | The whole page trail from location.pathname plus declared rules | the playground's "Derived from the path" card (apps/playground/src/app/components/breadcrumbs/gallery.tsx) | The app keeps its vocabulary (labels, page-less segments, namers) and stops rewriting the walk |
Banned combinations#
Do not pass every crumb an href. The habit comes from daisyUI's own breadcrumbs examples, where every item is an anchor; here the current page would link to itself and a page-less group segment would 404, so leave both without one (deriveCrumbs already refuses the last-crumb half). Do not render the trail preset when the app's own topbar already carries the crumb; one location line per page.
Before you adopt this#
Five questions to answer before reaching for Breadcrumbs.
- Does the shell, a parent layout, or a global provider already render this? The app's own topbar may already carry the crumb; one location line per page.
- Does this app already ship a local implementation of the same thing? Not applicable, no local Breadcrumbs clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Breadcrumbs prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? Kit sets
aria-currenton the last crumb; omithrefthere or it links nowhere. - Which canon §A rules bind this surface, and which does the composition break? §A7 hides a trail under two levels deep; §A4 fixes its position first.
Travels with#
PageTitle, directly below it, in the house page header (showcase-title.tsx,doc-title.tsx): the trail says where, the title says what.deriveCrumbs, when the app wants the trail fromlocation.pathnameplus its own rules rather than a hand-built list; the caller appends what a path cannot express.- The app router's
LinkthroughlinkAs, so crumbs prefetch and navigate client-side; a plain<a>otherwise. Skeletoninternally, for apendingcrumb whose name has not loaded; not a caller concern.
Snippet#
import { Breadcrumbs, PageTitle, deriveCrumbs } from "@versable-git/ui";import Link from "next/link";<div className="flex flex-col gap-2"> <Breadcrumbs presets="trail" linkAs={Link} items={deriveCrumbs(pathname, { labels: { jobs: "Jobs" }, noPage: ["admin"] })} /> <PageTitle title={job.name} /></div>