Breadcrumb
The path to the current page in the app header, with a sibling switcher on the last crumb.
Northwind Freight
Negotiation, $184,000
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator,} from "@oration/canon/components/breadcrumb";import { Button } from "@oration/canon/components/button";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { Separator } from "@oration/canon/components/separator";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { Building2Icon, CheckIcon, ChevronsUpDownIcon, PanelLeftIcon, SearchIcon,} from "lucide-react";import Link from "next/link";import * as React from "react";export function Hero() { const companies = [ { id: "northwind", label: "Northwind Freight", description: "Negotiation, $184,000", }, { id: "halcyon", label: "Halcyon", description: "Proposal, $96,500" }, { id: "orchard", label: "Orchard Street", description: "Discovery, $42,000", }, { id: "brightline", label: "Brightline Logistics", description: "Qualified, $61,200", }, { id: "cobalt", label: "Cobalt Supply Co.", description: "Closed won, $128,000", }, ]; const [current, setCurrent] = React.useState("northwind"); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [rail, setRail] = React.useState(true); const searchId = React.useId(); const matches = companies.filter((company) => company.label.toLowerCase().includes(query.trim().toLowerCase()), ); const currentLabel = companies.find((company) => company.id === current)?.label ?? ""; return ( <div className="flex h-56 w-full max-w-3xl overflow-hidden rounded-xl bg-background text-left shadow-border"> {rail ? ( <div className="w-12 shrink-0 border-r border-border bg-sidebar" /> ) : null} <div className="flex min-w-0 flex-1 flex-col"> <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-3"> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label="Toggle sidebar" aria-pressed={rail} className="-ml-0.5 text-muted-foreground hover:text-foreground" onClick={() => setRail((value) => !value)} /> } > <PanelLeftIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Toggle sidebar</TooltipContent> </Tooltip> <Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" /> <Breadcrumb className="min-w-0"> <BreadcrumbList className="flex-nowrap text-13"> <BreadcrumbItem className="shrink-0"> <BreadcrumbLink render={<Link href="/companies" />} className="flex items-center gap-1.5 rounded-sm whitespace-nowrap" > <Building2Icon aria-hidden="true" className="size-3.5" /> Companies </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem className="min-w-0"> <Popover open={open} onOpenChange={(next) => { setOpen(next); if (!next) setQuery(""); }} > <PopoverTrigger render={<button type="button" />} aria-current="page" className="-mx-1.5 flex h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 font-medium text-foreground outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 data-popup-open:bg-accent" > <span className="truncate"> {currentLabel} </span> <ChevronsUpDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" /> <span className="sr-only"> , switch company </span> </PopoverTrigger> <PopoverContent align="start" sideOffset={6} className="w-80 gap-0 overflow-hidden p-0" > <div className="flex h-10 items-center gap-2 border-b border-border px-3"> <SearchIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" /> <label htmlFor={searchId} className="sr-only" > Find a company </label> <input id={searchId} value={query} onChange={(event) => setQuery(event.target.value) } placeholder="Find a company…" autoComplete="off" className="h-full min-w-0 flex-1 bg-transparent text-13 outline-none placeholder:text-muted-foreground" /> </div> {matches.length ? ( <ul className="flex max-h-80 flex-col gap-px overflow-y-auto p-1"> {matches.map((company) => ( <li key={company.id}> <button type="button" onClick={() => { setCurrent( company.id, ); setOpen(false); setQuery(""); }} className="flex min-h-8 w-full items-center gap-2.5 rounded-md px-2 py-1 text-left text-13 outline-none transition-colors duration-150 hover:bg-accent focus-visible:bg-accent" > <span className="flex min-w-0 flex-1 flex-col"> <span className={cn( "truncate", company.id === current && "font-medium", )} > { company.label } </span> <span className="truncate text-xs text-muted-foreground"> { company.description } </span> </span> {company.id === current ? ( <> <CheckIcon aria-hidden="true" className="size-3.5 shrink-0" /> <span className="sr-only"> , current </span> </> ) : null} </button> </li> ))} </ul> ) : ( <p role="status" className="px-3 py-6 text-center text-13 text-muted-foreground" > No matches </p> )} </PopoverContent> </Popover> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> <div className="ml-auto flex shrink-0 items-center gap-1.5"> <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Link copied", description: `Anyone in Cedarline can open ${currentLabel}.`, }) } > Share </Button> </div> </header> <div className="flex flex-col gap-1 px-6 py-5"> <h3 className="text-xl font-semibold tracking-[-0.015em]"> {currentLabel} </h3> <p className="text-13 text-muted-foreground"> { companies.find((company) => company.id === current) ?.description } </p> </div> </div> </div> );}Usage#
Breadcrumbs show where a page sits: Companies, then Northwind Freight. In Oration they live in one place, the 48px app header, set in 13px Body Dense with the current crumb in ink at medium weight. On detail pages the last crumb is a sibling switcher: its label opens a searchable list of the other companies, deals or agents, so you can move sideways without going back to the index. The mistake to avoid is treating breadcrumbs as the page title. The header's crumbs are wayfinding; the page still has one h1.
When to use
- In the app header of every workspace page, from the app or section down to the current record.
- On detail pages, with a sibling switcher on the last crumb so people can jump to the next deal or company.
- When the path has two to four levels that each have a page of their own.
When not to use
- As the only heading of a page. Pages still render one h1, visibly or as screen reader text through the header's
heading. Use Page title - For moving between views of the same record. Use Tabs
- For steps of a flow, where order matters and later steps aren't reachable yet. Use Stepper
- For primary navigation between apps and sections. Use Sidebar
The Thirteen-Fourteen Rule
text-13 and font-medium.One h1 per page
heading so the header renders the h1 as screen reader text.Anatomy#
- Breadcrumb list. A
navwith an ordered list. In the header it doesn't wrap: parents keep their width and the last crumb truncates. - Link. A parent level in Slate Meta that turns ink on hover, rendered through
render={<Link href />}. An optional 14px icon sits 6px before it. - Separator. A 14px chevron in its own hidden list item, with 6px on each side.
- Current page. The last crumb, in ink at medium weight, marked
aria-current="page". On detail pages it is the switcher button. - Switcher chevron. A 14px up-down chevron in Slate Meta after the current label. It opens a 20rem popover with a search field and the sibling list.
Examples#
Basic
The primitives on their own: an ordered list of links, chevron separators and the current page. They default to 14px Slate Meta with the current page in ink.
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,} from "@oration/canon/components/breadcrumb";import Link from "next/link";export function Basic() { return ( <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink render={<Link href="/companies" />}> Companies </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink render={<Link href="/companies" />}> Northwind Freight </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Invoices</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> );}In the app header
How AppHeader sets them on an index or settings page: 13px, no wrapping, a 14px icon on the first crumb, parents that never shrink and a current crumb in medium weight. Detail pages end in the sibling switcher instead, shown at the top of this page: a 20rem popover with a search field and the siblings, the current one checked. The top demo recreates it from Popover; in the app, pass switcher on the last Crumb and the header renders the real one, with arrow keys, a gliding highlight and lazily loaded presets.
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,} from "@oration/canon/components/breadcrumb";import { SettingsIcon } from "lucide-react";import Link from "next/link";export function InHeader() { return ( <div className="flex h-12 w-full max-w-2xl items-center gap-2 rounded-xl bg-background px-3 shadow-border"> <Breadcrumb className="min-w-0"> <BreadcrumbList className="flex-nowrap text-13"> <BreadcrumbItem className="shrink-0"> <BreadcrumbLink render={<Link href="/settings" />} className="flex items-center gap-1.5 rounded-sm whitespace-nowrap" > <SettingsIcon aria-hidden="true" className="size-3.5" /> Settings </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem className="shrink-0"> <BreadcrumbLink render={<Link href="/settings" />} className="rounded-sm whitespace-nowrap" > Contact Center </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem className="min-w-0"> <BreadcrumbPage className="flex min-w-0 items-center gap-1.5 font-medium"> <span className="truncate">Queues</span> </BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> </div> );}Truncation
In a narrow header only the last crumb gives way. Parents keep shrink-0, the last item takes min-w-0, and its label truncates.
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,} from "@oration/canon/components/breadcrumb";import Link from "next/link";export function Truncation() { return ( <div className="flex h-12 w-full max-w-[19rem] items-center rounded-xl bg-background px-3 shadow-border"> <Breadcrumb className="min-w-0"> <BreadcrumbList className="flex-nowrap text-13"> <BreadcrumbItem className="shrink-0"> <BreadcrumbLink render={<Link href="/deals" />} className="rounded-sm whitespace-nowrap" > Deals </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem className="min-w-0"> <BreadcrumbPage className="flex min-w-0 items-center font-medium"> <span className="truncate"> Northwind Freight, AP automation rollout for four warehouses </span> </BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> </div> );}Collapsed path
For a deep path, fold the middle levels behind the ellipsis. The ellipsis is decorative, so wrap it in a labelled menu trigger that lists the hidden levels as links.
import { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,} from "@oration/canon/components/breadcrumb";import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import Link from "next/link";export function Collapsed() { return ( <Breadcrumb> <BreadcrumbList className="text-13"> <BreadcrumbItem> <BreadcrumbLink render={<Link href="/settings" />}> Settings </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="icon-xs" aria-label="Show 2 more levels" className="text-muted-foreground" /> } > <BreadcrumbEllipsis /> </DropdownMenuTrigger> <DropdownMenuContent className="w-48"> <DropdownMenuItem render={<Link href="/settings" />} > Contact Center </DropdownMenuItem> <DropdownMenuItem render={<Link href="/settings" />} > Queues </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage className="font-medium"> Priority suppliers </BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> );}States#
import { cn } from "@oration/canon/lib/utils";import { ChevronsUpDownIcon } from "lucide-react";import Link from "next/link";export function StatesMatrix() { const trigger = "flex h-7 items-center gap-1.5 rounded-md px-1.5 text-13 font-medium text-foreground"; const states = [ { label: "Rest", link: "", switcher: "" }, { label: "Hover", link: "text-foreground", switcher: "bg-accent" }, { label: "Focus", link: "rounded-[6px] outline-2 outline-offset-2 outline-ring", switcher: "ring-2 ring-ring/50", }, { label: "Open", link: null, switcher: "bg-accent" }, ]; return ( <div className="grid w-full grid-cols-[5rem_repeat(4,minmax(0,1fr))] items-center gap-x-3 gap-y-4 overflow-x-auto" inert > <span /> {states.map((state) => ( <span key={state.label} className="text-xs text-muted-foreground" > {state.label} </span> ))} <span className="text-13 text-muted-foreground">Link</span> {states.map((state) => ( <span key={state.label} className="text-13 text-muted-foreground" > {state.link === null ? null : ( <span className={cn("transition-colors", state.link)}> Companies </span> )} </span> ))} <span className="text-13 text-muted-foreground">Switcher</span> {states.map((state) => ( <span key={state.label} className="flex"> <span className={cn(trigger, state.switcher)}> Halcyon <ChevronsUpDownIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> </span> </span> ))} </div> );}| State | Treatment |
|---|---|
| Link rest | Slate Meta text. |
| Link hover | Ink, over the 150ms color transition. |
| Link focus visible | The global link ring: a 2px Focus Indigo outline at a 2px offset with 6px corners. |
| Current page | Ink at medium weight, not interactive, aria-current="page". |
| Switcher hover | The 28px trigger fills Menu Hover behind the label and chevron. |
| Switcher open | The Menu Hover fill stays while the popover is open (data-popup-open). |
| Switcher focus visible | A 2px Focus Indigo ring at 50% around the trigger. |
| Truncated | A long current label truncates with an ellipsis; parent crumbs never shrink. |
Behavior#
BreadcrumbLinkrenders an anchor, or any element throughrender. In the app always passrender={<Link href="…" />}so navigation stays client-side.- The app header renders crumbs from data:
AppHeader({ crumbs: { label, href?, icon?, render?, switcher? }[] })inapps/web/src/components/shell/page-header.tsx. Parents with anhrefbecome links; the last crumb, or any without anhref, becomes the page. switcheron the last crumb takes a preset ("companies","deals","people","lists","tables","meetings","sequences","workflows","agents") or{ items, label?, searchPlaceholder?, currentId? }. Items are{ id, label, href, icon?, description? }.- The switcher is a Popover aligned to the crumb's start, 6px below it and 20rem wide. Opening moves focus to its search field; typing filters, Up and Down move the highlight, Enter opens that sibling and Escape closes and returns focus to the crumb.
- The current sibling is checked and set in medium weight. A highlight glides between rows under the pointer, the list scrolls inside a 20rem cap with a 16px fade on the clipped edge, and an empty search says No matches.
- Preset lists load lazily the first time the pointer or focus reaches the crumb, so the popover opens with its list ready.
- When a crumb is already a control, such as an editable record name passed through
render, the switcher renders as a 24px chevron-only button beside it, labelled Switch deal. - Under 768px the switcher trigger grows to 32px and its rows to 40px for touch.
Do and don't#
Content#
- Each crumb is the destination page's own name, in sentence case, matching the sidebar and the page title.
- Records are named by their display name, never their ID. Use the mono ID in the page body if it matters.
- Switcher descriptions are one short fact: the stage and amount, the owner, or the last activity.
- Search placeholders name the noun: Find a company…, Find a deal….
Accessibility#
Breadcrumbis anavwitharia-label="breadcrumb", andBreadcrumbListis an ordered list, so the path is announced as a list of levels.- Separators and the ellipsis are
aria-hidden; screen readers hear only the crumbs. - The current crumb carries
aria-current="page". The switcher trigger keeps it and adds screen reader text such as , switch company, so its purpose is announced. - The switcher's search field is a combobox with
aria-activedescendant, and each row is an option. The current row adds , current after its name. - Links use the global focus ring. The switcher uses a 2px ring at 50%.
- Hit areas: links are text-height in a 48px bar, and the switcher is 28px tall (32px under 768px).
| Keys | Action |
|---|---|
| Tab | Moves through the parent links and the switcher. |
| Enter | Follows a link or opens the switcher. |
| ↑↓ | In the switcher, moves the highlight through the siblings. |
| Enter | In the switcher, opens the highlighted sibling. |
| Esc | Closes the switcher and returns focus to the crumb. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Parent links, separators, chevron |
--foreground | Hovered links and the current crumb |
--accent | Switcher hover, open and highlighted row |
--ring | Link outline and switcher ring |
--border | The header's bottom hairline and the switcher search rule |
--popover | The switcher surface |
text-13 | Crumb size in the header |
--radius-md | 8px switcher trigger and rows |
API reference#
Breadcrumb
The landmark.
Other props spread onto <nav>.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | "breadcrumb" | The landmark's name. Override it when a page has two. |
className | string | No default | The header passes min-w-0 so the list can truncate. |
BreadcrumbList
The ordered list of crumbs.
Other props spread onto <ol>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground. The header passes flex-nowrap text-13. |
BreadcrumbItem
One level.
Other props spread onto <li>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | The header passes shrink-0 to parents and min-w-0 to the last item. |
BreadcrumbLink
A parent level that links to its page.
Other props spread onto <a> through Base UI useRender.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render a Next Link instead of a plain anchor. |
href | string | No default | The destination, when not using render. |
className | string | No default | Merged after transition-colors hover:text-foreground. |
BreadcrumbPage
The current page. Renders a span with aria-current="page".
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after font-normal text-foreground. Pass font-medium to match the header. |
BreadcrumbSeparator
The hidden item between two crumbs.
Other props spread onto <li>.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | <ChevronRightIcon /> | Replaces the chevron. Canon uses the default. |
BreadcrumbEllipsis
A 20px horizontal ellipsis for collapsed levels. It is decorative; wrap it in a labelled button to make it expand.
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged last. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
BreadcrumbList defaults to text-sm (14px) and BreadcrumbPage to font-normal. DESIGN.md sets breadcrumbs at 13px with the current crumb in medium weight, so every use has to override both, as the app header does.
BreadcrumbPage renders role="link" with aria-disabled="true", so screen readers announce the current page as a disabled link. aria-current="page" on a plain span says the same thing without the fake role.
BreadcrumbEllipsis is aria-hidden, so its screen reader text More is never read, and it is a span rather than a button. Collapsed paths need a labelled menu trigger around it.
The sibling switcher lives in apps/web/src/components/shell/breadcrumb-switcher.tsx, not in packages/canon. It is only reachable through AppHeader's Crumb.switcher, and its trigger uses a 2px focus ring where other controls use 3px.
The landmark name is the lowercase breadcrumb, against sentence case everywhere else.