Skip to content

Breadcrumb

The path to the current page in the app header, with a sibling switcher on the last crumb.

Status
Beta
Category
Navigation
Adoption
Not used yet
import { Breadcrumb } from "@oration/canon/components/breadcrumb";
packages/canon/src/components/breadcrumb.tsx

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

Breadcrumbs are dense UI: 13px Body Dense, parents in Slate Meta, the current crumb in ink at medium weight. The primitive defaults to 14px and normal weight, so the header passes text-13 and font-medium.

One h1 per page

The header's crumbs are navigation, not a heading. Dense list pages that show no visible title pass heading so the header renders the h1 as screen reader text.

Anatomy#

  1. Breadcrumb list. A nav with an ordered list. In the header it doesn't wrap: parents keep their width and the last crumb truncates.
  2. 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.
  3. Separator. A 14px chevron in its own hidden list item, with 6px on each side.
  4. Current page. The last crumb, in ink at medium weight, marked aria-current="page". On detail pages it is the switcher button.
  5. 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#

RestHoverFocusOpenLinkCompaniesCompaniesCompaniesSwitcherHalcyonHalcyonHalcyonHalcyon
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>    );}
States
StateTreatment
Link restSlate Meta text.
Link hoverInk, over the 150ms color transition.
Link focus visibleThe global link ring: a 2px Focus Indigo outline at a 2px offset with 6px corners.
Current pageInk at medium weight, not interactive, aria-current="page".
Switcher hoverThe 28px trigger fills Menu Hover behind the label and chevron.
Switcher openThe Menu Hover fill stays while the popover is open (data-popup-open).
Switcher focus visibleA 2px Focus Indigo ring at 50% around the trigger.
TruncatedA long current label truncates with an ellipsis; parent crumbs never shrink.

Behavior#

  • BreadcrumbLink renders an anchor, or any element through render. In the app always pass render={<Link href="…" />} so navigation stays client-side.
  • The app header renders crumbs from data: AppHeader({ crumbs: { label, href?, icon?, render?, switcher? }[] }) in apps/web/src/components/shell/page-header.tsx. Parents with an href become links; the last crumb, or any without an href, becomes the page.
  • switcher on 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#

Do. Keep parents short and let only the last crumb truncate, so the path is always readable.
Don't. Let the crumb list wrap onto a second line in a 48px header. The header grows, and the path reads as two rows.
Do. Give detail pages a sibling switcher on the last crumb, with the current item checked.
Don't. Make people go back to the index to reach the next record. Every detail page in every app passes a switcher.
Do. Name each level the way the sidebar names it: Companies, then the record's own name.
Don't. Repeat the section in the record crumb or add the record type: Companies, then Company: Northwind Freight.

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#

  • Breadcrumb is a nav with aria-label="breadcrumb", and BreadcrumbList is 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).
Keyboard interactions
KeysAction
TabMoves through the parent links and the switcher.
EnterFollows a link or opens the switcher.
↑↓In the switcher, moves the highlight through the siblings.
EnterIn the switcher, opens the highlighted sibling.
EscCloses the switcher and returns focus to the crumb.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundParent links, separators, chevron
--foregroundHovered links and the current crumb
--accentSwitcher hover, open and highlighted row
--ringLink outline and switcher ring
--borderThe header's bottom hairline and the switcher search rule
--popoverThe switcher surface
text-13Crumb size in the header
--radius-md8px switcher trigger and rows

API reference#

Breadcrumb

The landmark.

Other props spread onto <nav>.

Props of Breadcrumb
PropTypeDefaultDescription
aria-labelstring"breadcrumb"The landmark's name. Override it when a page has two.
classNamestringNo defaultThe header passes min-w-0 so the list can truncate.

BreadcrumbList

The ordered list of crumbs.

Other props spread onto <ol>.

Props of BreadcrumbList
PropTypeDefaultDescription
classNamestringNo defaultMerged 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>.

Props of BreadcrumbItem
PropTypeDefaultDescription
classNamestringNo defaultThe header passes shrink-0 to parents and min-w-0 to the last item.

BreadcrumbPage

The current page. Renders a span with aria-current="page".

Other props spread onto <span>.

Props of BreadcrumbPage
PropTypeDefaultDescription
classNamestringNo defaultMerged after font-normal text-foreground. Pass font-medium to match the header.

BreadcrumbSeparator

The hidden item between two crumbs.

Other props spread onto <li>.

Props of BreadcrumbSeparator
PropTypeDefaultDescription
childrenReactNode<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>.

Props of BreadcrumbEllipsis
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.