A trail of links to the current page that wraps safely, mirrors in right-to-left layouts and expands collapsed segments in place.
import {
Breadcrumb,
BreadcrumbCollapse,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbDemo() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbCollapse>
<BreadcrumbItem>
<BreadcrumbLink href="#">Workspace</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Design system</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbCollapse>
<BreadcrumbItem>
<BreadcrumbLink href="#">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/breadcrumb.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react cn - 3
Copy and paste the following code into your project.
components/ui/breadcrumb.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconChevronRight, IconDots } from "@tabler/icons-react" import { cn } from "cn" import { duration, easeSpring } from "@/lib/motion" type BreadcrumbCollapseContextValue = { group: string open: boolean settled: boolean } const BreadcrumbCollapseContext = React.createContext<BreadcrumbCollapseContextValue | null>(null) function collapseAttributes( context: BreadcrumbCollapseContextValue | null, collapsed: boolean ) { if (!context) { return {} } return { "data-breadcrumb-group": context.group, "data-collapsed": collapsed && context.settled ? "" : undefined, } as Record<string, string | undefined> } function Breadcrumb({ className, render, ...props }: useRender.ComponentProps<"nav">) { return useRender({ defaultTagName: "nav", render, props: mergeProps<"nav">( { "aria-label": "Breadcrumb", className: cn("min-w-0", className) }, props, { "data-slot": "breadcrumb" } as React.ComponentProps<"nav"> ), }) } function BreadcrumbList({ className, render, ...props }: useRender.ComponentProps<"ol">) { return useRender({ defaultTagName: "ol", render, props: mergeProps<"ol">( { className: cn( "flex min-w-0 flex-wrap items-center gap-1.5 text-sm wrap-anywhere text-muted-foreground", className ), }, props, { "data-slot": "breadcrumb-list" } as React.ComponentProps<"ol"> ), }) } function BreadcrumbItem({ className, render, ...props }: useRender.ComponentProps<"li">) { const collapse = React.useContext(BreadcrumbCollapseContext) return useRender({ defaultTagName: "li", render, props: mergeProps<"li">( { className: cn( "inline-flex min-w-0 items-center gap-1 data-collapsed:hidden", className ), }, props, { "data-slot": "breadcrumb-item", ...collapseAttributes(collapse, collapse ? !collapse.open : false), } as React.ComponentProps<"li"> ), }) } function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) { return useRender({ defaultTagName: "a", render, props: mergeProps<"a">( { className: cn( "relative -mx-1 inline-flex min-w-0 items-center gap-1 rounded-sm px-1 transition-[color,box-shadow] duration-150 ease-out-quint outline-none hover:text-foreground focus-visible:z-10 focus-visible:text-foreground focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4", className ), }, props, { "data-slot": "breadcrumb-link" } as React.ComponentProps<"a"> ), }) } function BreadcrumbPage({ className, render, ...props }: useRender.ComponentProps<"span">) { return useRender({ defaultTagName: "span", render, props: mergeProps<"span">( { "aria-current": "page", className: cn( "inline-flex min-w-0 items-center gap-1 font-normal text-foreground [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4", className ), }, props, { "data-slot": "breadcrumb-page" } as React.ComponentProps<"span"> ), }) } function BreadcrumbSeparator({ children, className, render, ...props }: useRender.ComponentProps<"li">) { const collapse = React.useContext(BreadcrumbCollapseContext) return useRender({ defaultTagName: "li", render, props: mergeProps<"li">( { role: "presentation", "aria-hidden": true, children: children ?? <IconChevronRight className="rtl:-scale-x-100" />, className: cn( "inline-flex shrink-0 items-center text-muted-foreground/60 select-none [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3.5 [&[data-collapsed]:has(+[data-collapsed])]:hidden", className ), }, props, { "data-slot": "breadcrumb-separator", ...collapseAttributes(collapse, collapse ? !collapse.open : false), } as React.ComponentProps<"li"> ), }) } function BreadcrumbEllipsis({ children, className, render, ...props }: useRender.ComponentProps<"span">) { const interactive = render !== undefined || props.onClick !== undefined return useRender({ defaultTagName: "span", render, props: mergeProps<"span">( { ...(interactive ? {} : { role: "presentation", "aria-hidden": true as const }), children: children ?? ( <> <IconDots aria-hidden /> <span className="sr-only">More</span> </> ), className: cn( "relative inline-flex size-5 shrink-0 items-center justify-center rounded-sm outline-none focus-visible:outline-hidden [&:is(a,button)]:size-6 [&:is(a,button)]:cursor-pointer [&:is(a,button)]:transition-[color,background-color,box-shadow] [&:is(a,button)]:duration-150 [&:is(a,button)]:ease-out-quint [&:is(a,button)]:after:absolute [&:is(a,button)]:after:-inset-x-1 [&:is(a,button)]:after:-inset-y-2.5 [&:is(a,button)]:hover:bg-muted [&:is(a,button)]:hover:text-foreground [&:is(a,button)]:focus-visible:z-10 [&:is(a,button)]:focus-visible:text-foreground [&:is(a,button)]:focus-visible:ring-3 [&:is(a,button)]:focus-visible:ring-focus-ring [&:is(a,button)]:aria-expanded:bg-muted [&:is(a,button)]:aria-expanded:text-foreground [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-4", className ), }, props, { "data-slot": "breadcrumb-ellipsis" } as React.ComponentProps<"span"> ), }) } const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' function groupElements(anchor: HTMLElement, group: string) { const list = anchor.parentElement if (!list) { return [] } return Array.from(list.children).filter( (node): node is HTMLElement => node instanceof HTMLElement && node.dataset.breadcrumbGroup === group ) } function togglesWithCollapse(element: HTMLElement, group: string) { if (element.dataset.slot !== "breadcrumb-separator") { return true } const next = element.nextElementSibling return next instanceof HTMLElement && next.dataset.breadcrumbGroup === group } function clearAnimationStyles(element: HTMLElement) { element.style.overflow = "" element.style.whiteSpace = "" element.style.minWidth = "" } type BreadcrumbCollapseProps = { children?: React.ReactNode open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void label?: string } function BreadcrumbCollapse({ children, open: openProp, defaultOpen = false, onOpenChange, label = "Show full path", }: BreadcrumbCollapseProps) { const group = React.useId() const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const [previousOpen, setPreviousOpen] = React.useState(open) const [settled, setSettled] = React.useState(true) const triggerItemRef = React.useRef<HTMLLIElement>(null) const animationsRef = React.useRef(new Map<HTMLElement, Animation>()) const runRef = React.useRef(0) const restoreFocusRef = React.useRef(false) if (open !== previousOpen) { setPreviousOpen(open) setSettled(false) } React.useLayoutEffect(() => { const triggerItem = triggerItemRef.current if (settled || !triggerItem) { return } const run = ++runRef.current const animations = animationsRef.current const elements = [triggerItem, ...groupElements(triggerItem, group)] const toggled = elements.filter( (element) => element === triggerItem || togglesWithCollapse(element, group) ) if (toggled.some((element) => element.contains(document.activeElement))) { restoreFocusRef.current = true } if (typeof triggerItem.animate !== "function") { setSettled(true) return } const reduceMotion = typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches const list = triggerItem.parentElement const gap = list ? parseFloat(getComputedStyle(list).columnGap) || 0 : 0 const starts = new Map<HTMLElement, number | null>() for (const element of toggled) { const running = animations.get(element) starts.set( element, running ? element.getBoundingClientRect().width : null ) running?.cancel() animations.delete(element) } const pending = toggled.map((element) => { const entering = element === triggerItem ? !open : open const natural = element.getBoundingClientRect().width const start = starts.get(element) ?? (entering ? 0 : natural) const end = entering ? natural : 0 const progress = natural > 0 ? start / natural : entering ? 0 : 1 element.style.overflow = "hidden" element.style.whiteSpace = "nowrap" element.style.minWidth = "0px" const animation = element.animate( [ { width: `${start}px`, marginInlineEnd: `${-gap * (1 - progress)}px`, opacity: progress, }, { width: `${end}px`, marginInlineEnd: entering ? "0px" : `${-gap}px`, opacity: entering ? 1 : 0, }, ], { duration: reduceMotion ? 0 : duration.morph, easing: easeSpring, fill: "forwards", } ) animations.set(element, animation) return animation.finished }) Promise.all(pending).then( () => { if (runRef.current === run) { setSettled(true) } }, () => {} ) }, [group, open, settled]) React.useLayoutEffect(() => { if (!settled) { return } const animations = animationsRef.current for (const [element, animation] of animations) { animation.cancel() clearAnimationStyles(element) } animations.clear() const triggerItem = triggerItemRef.current if (!restoreFocusRef.current || !triggerItem) { return } restoreFocusRef.current = false const target = open ? groupElements(triggerItem, group) .map((element) => element.matches(focusableSelector) ? element : element.querySelector<HTMLElement>(focusableSelector) ) .find((element) => element?.checkVisibility?.() ?? true) : triggerItem.querySelector<HTMLElement>(focusableSelector) target?.focus() }, [group, open, settled]) React.useEffect(() => { const animations = animationsRef.current return () => { for (const [element, animation] of animations) { animation.cancel() clearAnimationStyles(element) } animations.clear() } }, []) const context = React.useMemo( () => ({ group, open, settled }), [group, open, settled] ) return ( <BreadcrumbCollapseContext.Provider value={context}> <li ref={triggerItemRef} data-slot="breadcrumb-collapse" data-collapsed={open && settled ? "" : undefined} className="inline-flex shrink-0 items-center data-collapsed:hidden" > <BreadcrumbEllipsis render={<button type="button" aria-label={label} />} tabIndex={open ? -1 : undefined} onClick={() => { if (openProp === undefined) { setUncontrolledOpen(true) } onOpenChange?.(true) }} /> </li> {children} </BreadcrumbCollapseContext.Provider> ) } export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis, BreadcrumbCollapse, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>Breadcrumb
└── BreadcrumbList
├── BreadcrumbItem
│ └── BreadcrumbLink
├── BreadcrumbSeparator
├── BreadcrumbCollapse
│ ├── BreadcrumbItem
│ └── BreadcrumbSeparator
└── BreadcrumbItem
└── BreadcrumbPageimport {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbBasic() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}With icons#
Icons sit inside links and the current page. Give an icon-only link an aria-label.
import { IconFolder, IconHome } from "@tabler/icons-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbWithIcons() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" aria-label="Home">
<IconHome />
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">
<IconFolder />
Projects
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
<IconFolder />
hextaui
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}Custom separator#
Pass any element as children of <BreadcrumbSeparator /> to replace the chevron.
import { IconPointFilled } from "@tabler/icons-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbCustomSeparator() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<IconPointFilled className="size-2" />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href="#">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<IconPointFilled className="size-2" />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}Dropdown#
Render a <BreadcrumbLink /> as the trigger of a <DropdownMenu /> to offer sibling pages.
import { IconChevronDown } from "@tabler/icons-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function BreadcrumbDropdown() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<BreadcrumbLink render={<button type="button" />} />}
>
Components
<IconChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem>Documentation</DropdownMenuItem>
<DropdownMenuItem>Themes</DropdownMenuItem>
<DropdownMenuItem>GitHub</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}Collapsed#
Wrap middle segments in <BreadcrumbCollapse />. They hide behind an ellipsis and slide open in place, and focus moves to the first revealed link. Pass open and onOpenChange to control it.
"use client"
import * as React from "react"
import {
Breadcrumb,
BreadcrumbCollapse,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
export function BreadcrumbControlled() {
const [open, setOpen] = React.useState(false)
return (
<div className="flex flex-col items-center gap-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbCollapse open={open} onOpenChange={setOpen}>
<BreadcrumbItem>
<BreadcrumbLink href="#">Documents</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">2026</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbCollapse>
<BreadcrumbItem>
<BreadcrumbPage>Invoices</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button variant="outline" size="sm" onClick={() => setOpen(!open)}>
{open ? "Collapse" : "Expand"}
</Button>
</div>
)
}Ellipsis#
A static <BreadcrumbEllipsis /> marks skipped levels. It is hidden from assistive tech unless you make it interactive.
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbEllipsisDemo() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}Link component#
Use the render prop to swap the anchor for your router's link, like Next.js <Link />.
import Link from "next/link"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbLinkComponent() {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/" />}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/docs" />}>Docs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}Long content#
Unbroken names wrap instead of overflowing, and segments with a max width truncate.
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbLongContent() {
return (
<div className="flex w-72 flex-col gap-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">
averyveryverylongunbrokenfoldernamethatshouldneverescape
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
An unusually long page title that wraps onto a second line
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">
<span className="max-w-24 truncate">
Customer success playbooks
</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
<span className="max-w-32 truncate">
Onboarding checklist for enterprise accounts
</span>
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
)
}Right to left#
Separators flip and the collapse animates from the correct side.
import {
Breadcrumb,
BreadcrumbCollapse,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
export function BreadcrumbRtl() {
return (
<div dir="rtl">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">الرئيسية</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbCollapse label="عرض المسار الكامل">
<BreadcrumbItem>
<BreadcrumbLink href="#">المكونات</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">التنقل</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</BreadcrumbCollapse>
<BreadcrumbItem>
<BreadcrumbPage>مسار التنقل</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
)
}Links and the collapse button are regular tab stops. Separators and a static ellipsis are skipped.
| Key | Action |
|---|---|
| TabShift + Tab | Moves between links and the collapse button. |
| Enter | Follows the focused link. |
| EnterSpace | On the collapse button, reveals the hidden segments. Once they finish sliding open, focus moves to the first revealed link. |
Every part accepts the attributes of the element it renders, plus a render prop to swap that element.
| Prop | Type | Default |
|---|---|---|
renderLabelled “Breadcrumb” for assistive tech. | ReactElement | (props, state) => ReactElement | <nav> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb" | The <nav> landmark. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <ol> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-list" | The ordered list. Items wrap onto new lines when space runs out. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <li> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-item" | Target items in CSS. |
data-collapsed | Present while the item is hidden inside a closed BreadcrumbCollapse. |
data-breadcrumb-group | Present on items inside a BreadcrumbCollapse. Shared by every part of the same group. |
| Prop | Type | Default |
|---|---|---|
href | string | – |
renderPass your router’s link component here. | ReactElement | (props, state) => ReactElement | <a> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-link" | Target links in CSS. |
| Prop | Type | Default |
|---|---|---|
renderMarked with aria-current="page". | ReactElement | (props, state) => ReactElement | <span> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-page" | The current page, with aria-current="page". |
| Prop | Type | Default |
|---|---|---|
childrenMirrors automatically in right-to-left layouts. | ReactNode | <IconChevronRight /> |
render | ReactElement | (props, state) => ReactElement | <li> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-separator" | Presentational and hidden from assistive tech. |
data-collapsed | Present while the separator is hidden inside a closed BreadcrumbCollapse. |
data-breadcrumb-group | Present on separators inside a BreadcrumbCollapse. |
| Prop | Type | Default |
|---|---|---|
children | ReactNode | <IconDots /> |
renderHidden from assistive tech unless render or onClick makes it interactive. | ReactElement | (props, state) => ReactElement | <span> |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-ellipsis" | Target the ellipsis in CSS. |
| Prop | Type | Default |
|---|---|---|
childrenThe items and separators to hide behind the ellipsis. | ReactNode | – |
open | boolean | – |
defaultOpen | boolean | false |
onOpenChange | (open: boolean) => void | – |
labelAccessible name of the ellipsis button. | string | "Show full path" |
| Attribute | Description |
|---|---|
data-slot="breadcrumb-collapse" | The <li> that holds the ellipsis button. |
data-collapsed | Present on the ellipsis item once the group has opened, which hides it. |