← ui

breadcrumb

ui

where a page sits, as the links above it, a long middle folded into ⋯

npx shadcn add @ag/breadcrumb
Already installed? Preview an update
npx shadcn add @ag/breadcrumb --dry-run

Your 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, or render for a router's own <Link />), the last the current page (aria-current="page", never a link), chevrons between (separator), in a nav named for a screen reader. Given max, a long path keeps its first crumb and its last max - 1 and folds the middle into ⋯, a dropdown of the crumbs it hides. It draws in its surface's currentColor and takes no scheme; material and container reach the ⋯ button and its dropdown.
Updated 2026-10-11 · release notes

shelf controls

uses button, material, menu

used by components

the demo above, verbatim

breadcrumb.demo.tsx
"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

breadcrumb.tsx
"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}
    </>
  )
}