where a page sits, as the links above it, a long middle folded into ⋯
npx shadcn add @ag/breadcrumbAlready installed? Preview an update
npx shadcn add @ag/breadcrumb --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-11
- New: where a page sits,
<Breadcrumb crumbs max />: the path of pages above it, each crumb a link (href, orrenderfor a router's own<Link />), the last the current page (aria-current="page", never a link), chevrons between (separator), in anavnamed for a screen reader. Givenmax, a long path keeps its first crumb and its lastmax - 1and folds the middle into ⋯, a dropdown of the crumbs it hides. It draws in its surface'scurrentColorand takes noscheme;materialandcontainerreach the ⋯ button and its dropdown.
the demo above, verbatim
"use client"
import Link from "next/link"
import { Sample } from "@/app/samples"
import { Breadcrumb, type Crumb } from "@/registry/base-nova/ui/breadcrumb"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
// #region path
// A page five levels down; the first crumb uses the router's own link.
const PATH: Crumb[] = [
{ label: "library", render: <Link href="/" /> },
{ label: "films", href: "#films" },
{ label: "1968", href: "#1968" },
{ label: "2001: a space odyssey", href: "#2001" },
{ label: "the dawn of man" },
]
// #endregion
export default function BreadcrumbDemo() {
return (
<div className="space-y-8">
<Sample name="plain" title="the whole path" with="path">
<Breadcrumb crumbs={PATH} />
</Sample>
<Sample
name="folded"
glance
title="folded past a length"
description="max 3: the first crumb, then ⋯ holding the ones between, then the last two"
with="path"
>
<Breadcrumb crumbs={PATH} max={3} />
</Sample>
<Sample
name="glass"
title="in glass, over a picture"
description="the path draws in the words' colour; the ⋯ and its dropdown are glass"
with="path"
>
<MaterialProvider material="glass">
<div className="rounded-2xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-10 text-white">
<Breadcrumb crumbs={PATH} max={3} />
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A BREADCRUMB: where this page sits, as the path of pages above it, each a link, the
// last the page you are on (`aria-current="page"`, never a link to itself). Given
// `max`, a long path keeps its first crumb and its last `max - 1` and folds the middle
// into ⋯, a dropdown (menu) of the crumbs it hides, so the path stays one line.
//
// <Breadcrumb
// max={4}
// crumbs={[
// { label: "library", href: "/" },
// { label: "films", href: "/films" },
// { label: "1968", href: "/films/1968" },
// { label: "2001", href: "/films/1968/2001" },
// { label: "the dawn of man" },
// ]}
// />
//
// A crumb links with `href` (an `<a>`), or with `render`, a router's own link
// (`render: <Link href="/films" />`), Base UI's way: the element is handed in, never
// wrapped.
//
// A LEAF, NOT A SURFACE: it is a line of text that sits on whatever holds it (a page's
// header, a panel, glass over a picture) and draws in that surface's `currentColor`,
// at alpha tiers, the way kbd and separator do. So it takes no `scheme` and wears no
// face of its own; `material` only reaches the two things in it that are controls of
// their own, the ⋯ button and the dropdown it opens.
import { useRender } from "@base-ui/react/use-render"
import { ChevronRight, Ellipsis } from "lucide-react"
import type * as React from "react"
import { Button } from "@/registry/base-nova/ui/button"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from "@/registry/base-nova/ui/menu"
/** One step of the path. */
export interface Crumb {
label: React.ReactNode
/** Where it goes. The last crumb is the page you are on and is never a link. */
href?: string
/** The link element itself, for a router's own (`<Link href="/films" />`). */
render?: React.ReactElement
}
export interface BreadcrumbProps {
/** The path, outermost first; the last is the current page. */
crumbs: readonly Crumb[]
/** The most crumbs shown (2 or more): past it, the middle folds into ⋯. */
max?: number
/** Drawn between crumbs; a chevron unless set. */
separator?: React.ReactNode
/** The ⋯ button's and its dropdown's material. */
material?: Material
/** Where the dropdown is portalled (a player's layer, so it shows in fullscreen). */
container?: React.ComponentProps<typeof DropdownMenuContent>["container"]
/** Names the path for a screen reader. */
label?: string
className?: string
}
const LINK = "rounded-sm transition-colors hover:text-current"
/** The element a crumb links with, or null when it has nowhere to go. */
const linkOf = (crumb: Crumb): React.ReactElement | null =>
crumb.render ??
// biome-ignore lint/a11y/useAnchorContent: the crumb's label is its content, given where it is drawn
(crumb.href ? <a href={crumb.href} /> : null)
/** A crumb above the current page: a link when it has somewhere to go, else its
* words. */
function CrumbLink({ crumb }: { crumb: Crumb }) {
const linked = linkOf(crumb)
const element = useRender({
render: linked ?? <span />,
props: {
"data-slot": linked ? "breadcrumb-link" : "breadcrumb-text",
className: linked ? LINK : undefined,
children: crumb.label,
},
})
return element
}
/** The folded crumbs, as a dropdown behind ⋯. */
function Folded({
crumbs,
material,
container,
}: {
crumbs: readonly Crumb[]
material: Material
container: BreadcrumbProps["container"]
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
data-slot="breadcrumb-ellipsis"
render={
<Button
variant="ghost"
size="icon-sm"
material={material}
aria-label={
crumbs.length === 1
? "the page between"
: `the ${crumbs.length} pages between`
}
className="-my-1"
/>
}
>
<Ellipsis />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
material={material}
container={container}
>
{crumbs.map((crumb, i) => {
const linked = linkOf(crumb)
return linked ? (
// biome-ignore lint/suspicious/noArrayIndexKey: a path is positional
<DropdownMenuLinkItem key={i} render={linked}>
{crumb.label}
</DropdownMenuLinkItem>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: a path is positional
<DropdownMenuItem key={i} disabled>
{crumb.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function Breadcrumb({
crumbs,
max,
separator = <ChevronRight />,
material,
container,
label = "breadcrumb",
className,
}: BreadcrumbProps) {
if (crumbs.length === 0) throw new Error("Breadcrumb: no crumbs")
if (max !== undefined && (!Number.isInteger(max) || max < 2))
throw new Error(
`Breadcrumb: max is the most crumbs shown, 2 or more (got ${max})`,
)
const m = useMaterial(material)
const here = crumbs[crumbs.length - 1] as Crumb
const above = crumbs.slice(0, -1)
// Folded: the first crumb, ⋯, then the last max - 1 (the current page among them).
const fold = max !== undefined && crumbs.length > max
const kept = fold ? crumbs.length - (max - 1) : 1
const steps: ({ crumb: Crumb } | { folded: readonly Crumb[] })[] = fold
? [
{ crumb: above[0] as Crumb },
{ folded: above.slice(1, kept) },
...above.slice(kept).map((crumb) => ({ crumb })),
]
: above.map((crumb) => ({ crumb }))
const line = (
<li
role="presentation"
aria-hidden
data-slot="breadcrumb-separator"
className="flex text-current/35 [&_svg]:size-3.5"
>
{separator}
</li>
)
return (
<nav aria-label={label} data-slot="breadcrumb" className={className}>
<ol className="flex flex-wrap items-center gap-1.5 break-words text-current/60 text-sm">
{steps.map((step, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a path is positional
<BreadcrumbStep key={i} line={line}>
{"crumb" in step ? (
<CrumbLink crumb={step.crumb} />
) : (
<Folded crumbs={step.folded} material={m} container={container} />
)}
</BreadcrumbStep>
))}
<li data-slot="breadcrumb-item" className="flex min-w-0">
<span
aria-current="page"
data-slot="breadcrumb-page"
className="truncate font-medium text-current"
>
{here.label}
</span>
</li>
</ol>
</nav>
)
}
function BreadcrumbStep({
line,
children,
}: {
line: React.ReactNode
children: React.ReactNode
}) {
return (
<>
<li data-slot="breadcrumb-item" className="flex items-center">
{children}
</li>
{line}
</>
)
}