Component
Breadcrumbs Collapsed
Long breadcrumb paths with middle levels collapsed behind an accessible ellipsis disclosure — the hidden levels stay reachable as real links from a keyboard-operable menu.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/breadcrumbs/breadcrumbs-collapsed/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Breadcrumbs/breadcrumbs-collapsed React/Components/Breadcrumbs/breadcrumbs-collapsed import { createContext, useContext, useEffect, useRef, useState } from "react";
import type {
AnchorHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
LiHTMLAttributes,
ReactNode,
} from "react";
/**
* DevSnips React Breadcrumbs — Collapsed.
*
* Long paths collapse their middle levels behind `<BreadcrumbEllipsis>` —
* Home / … / Components / Buttons. The ellipsis is a real disclosure
* button (`aria-haspopup="menu"` + `aria-expanded`) that opens a menu of
* the hidden levels as real anchor links, so keyboard and pointer users
* can always reach the collapsed navigation. Nothing is hidden with CSS
* alone.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const LIST_CLASSES =
"m-0 flex max-w-full list-none flex-wrap items-center gap-x-1 gap-y-0.5 p-0 text-sm leading-5";
const ITEM_CLASSES = "inline-flex min-w-0 items-center gap-1.5";
const LINK_CLASSES =
"inline-flex min-w-0 items-center gap-1.5 rounded-[var(--ds-radius-xs)] text-[var(--ds-color-muted-foreground)] underline-offset-4 transition-colors duration-150 ease-out hover:text-[var(--ds-color-foreground)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const CURRENT_CLASSES =
"inline-flex min-w-0 items-center gap-1.5 font-medium text-[var(--ds-color-foreground)]";
const SEPARATOR_CLASSES =
"inline-flex shrink-0 select-none items-center justify-center text-[var(--ds-color-muted-foreground)] [&_svg]:size-3.5";
const ICON_CLASSES = "inline-flex shrink-0 text-[14px] [&_svg]:size-3.5";
const DEFAULT_SEPARATOR = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m9 6 6 6-6 6" />
</svg>
);
interface BreadcrumbsContextValue {
separator: ReactNode;
}
const BreadcrumbsContext = createContext<BreadcrumbsContextValue | null>(null);
function useBreadcrumbs(component: string): BreadcrumbsContextValue {
const context = useContext(BreadcrumbsContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Breadcrumbs>.`);
}
return context;
}
export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement> {
/** Accessible label for the navigation landmark. */
label?: string;
/** Default separator content used by every `<BreadcrumbSeparator>` without children. */
separator?: ReactNode;
className?: string;
children?: ReactNode;
}
export function Breadcrumbs({
label = "Breadcrumb",
separator,
className,
children,
...rest
}: BreadcrumbsProps) {
return (
<BreadcrumbsContext.Provider value={{ separator: separator ?? DEFAULT_SEPARATOR }}>
<nav aria-label={label} className={className} {...rest}>
{children}
</nav>
</BreadcrumbsContext.Provider>
);
}
export interface BreadcrumbListProps extends HTMLAttributes<HTMLOListElement> {
className?: string;
children?: ReactNode;
}
export function BreadcrumbList({ className, children, ...rest }: BreadcrumbListProps) {
return (
<ol className={cx(LIST_CLASSES, className)} {...rest}>
{children}
</ol>
);
}
export interface BreadcrumbItemProps extends LiHTMLAttributes<HTMLLIElement> {
className?: string;
children?: ReactNode;
}
export function BreadcrumbItem({ className, children, ...rest }: BreadcrumbItemProps) {
return (
<li className={cx(ITEM_CLASSES, className)} {...rest}>
{children}
</li>
);
}
export interface BreadcrumbLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** Destination URL — rendered as a real anchor with normal browser navigation. */
href: string;
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
className?: string;
children?: ReactNode;
}
export function BreadcrumbLink({ href, icon, className, children, ...rest }: BreadcrumbLinkProps) {
return (
<a href={href} className={cx(LINK_CLASSES, className)} {...rest}>
{icon ? (
<span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span>
) : null}
<span className="min-w-0">{children}</span>
</a>
);
}
export interface BreadcrumbCurrentProps extends HTMLAttributes<HTMLSpanElement> {
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
className?: string;
children?: ReactNode;
}
export function BreadcrumbCurrent({ icon, className, children, ...rest }: BreadcrumbCurrentProps) {
return (
<span aria-current="page" className={cx(CURRENT_CLASSES, className)} {...rest}>
{icon ? (
<span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span>
) : null}
<span className="min-w-0">{children}</span>
</span>
);
}
export interface BreadcrumbSeparatorProps extends LiHTMLAttributes<HTMLLIElement> {
className?: string;
children?: ReactNode;
}
export function BreadcrumbSeparator({ className, children, ...rest }: BreadcrumbSeparatorProps) {
const context = useBreadcrumbs("BreadcrumbSeparator");
return (
<li
role="presentation"
aria-hidden="true"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
>
{children ?? context.separator}
</li>
);
}
export interface BreadcrumbEllipsisItem {
/** Visible label. */
label: ReactNode;
/** Destination URL — rendered as a real anchor menu item. */
href: string;
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
}
export interface BreadcrumbEllipsisProps {
/** The collapsed levels, in path order. */
items: BreadcrumbEllipsisItem[];
/** Accessible name for the disclosure button. */
label?: string;
className?: string;
}
const ELLIPSIS_TRIGGER_CLASSES =
"inline-flex h-5 min-w-6 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] px-1 text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] aria-expanded:bg-[var(--ds-color-surface-active)] aria-expanded:text-[var(--ds-color-foreground)] motion-reduce:transition-none";
const MENU_CLASSES =
"absolute left-0 top-[calc(100%+4px)] z-40 min-w-[180px] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const MENU_ITEM_CLASSES =
"flex w-full items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-[13px] leading-5 text-[var(--ds-color-foreground)] no-underline transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
export function BreadcrumbEllipsis({
items,
label = "Show hidden breadcrumb levels",
className,
}: BreadcrumbEllipsisProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
const containerRef = useRef<HTMLLIElement>(null);
useEffect(() => {
if (!open) return;
function onDown(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
if (event.key === "Tab") {
setOpen(false);
}
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function openMenu(focusIndex: number) {
setOpen(true);
setTimeout(() => itemRefs.current[focusIndex]?.focus(), 0);
}
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
open ? itemRefs.current[0]?.focus() : openMenu(0);
} else if (event.key === "ArrowUp") {
event.preventDefault();
open ? itemRefs.current[items.length - 1]?.focus() : openMenu(items.length - 1);
}
}
function onItemKeyDown(event: ReactKeyboardEvent<HTMLAnchorElement>, index: number) {
const count = items.length;
if (event.key === "ArrowDown") {
event.preventDefault();
itemRefs.current[(index + 1) % count]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
itemRefs.current[(index - 1 + count) % count]?.focus();
} else if (event.key === "Home") {
event.preventDefault();
itemRefs.current[0]?.focus();
} else if (event.key === "End") {
event.preventDefault();
itemRefs.current[count - 1]?.focus();
}
}
return (
<li ref={containerRef} className={cx("relative inline-flex shrink-0 items-center", className)}>
<button
type="button"
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
onClick={() => (open ? setOpen(false) : openMenu(0))}
onKeyDown={onTriggerKeyDown}
className={ELLIPSIS_TRIGGER_CLASSES}
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
className="size-3.5"
>
<circle cx="5" cy="12" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="19" cy="12" r="1.5" />
</svg>
</button>
{open ? (
<div role="menu" aria-label={label} className={MENU_CLASSES}>
{items.map((item, index) => (
<a
key={item.href}
ref={(el) => {
itemRefs.current[index] = el;
}}
role="menuitem"
tabIndex={-1}
href={item.href}
className={MENU_ITEM_CLASSES}
onClick={() => setOpen(false)}
onKeyDown={(event) => onItemKeyDown(event, index)}
>
{item.icon ? (
<span aria-hidden="true" className={ICON_CLASSES}>
{item.icon}
</span>
) : null}
<span className="min-w-0 flex-1">{item.label}</span>
</a>
))}
</div>
) : null}
</li>
);
}
export default Breadcrumbs; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { createContext, useContext, useEffect, useRef, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const LIST_CLASSES = "m-0 flex max-w-full list-none flex-wrap items-center gap-x-1 gap-y-0.5 p-0 text-sm leading-5";
const ITEM_CLASSES = "inline-flex min-w-0 items-center gap-1.5";
const LINK_CLASSES = "inline-flex min-w-0 items-center gap-1.5 rounded-[var(--ds-radius-xs)] text-[var(--ds-color-muted-foreground)] underline-offset-4 transition-colors duration-150 ease-out hover:text-[var(--ds-color-foreground)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const CURRENT_CLASSES = "inline-flex min-w-0 items-center gap-1.5 font-medium text-[var(--ds-color-foreground)]";
const SEPARATOR_CLASSES = "inline-flex shrink-0 select-none items-center justify-center text-[var(--ds-color-muted-foreground)] [&_svg]:size-3.5";
const ICON_CLASSES = "inline-flex shrink-0 text-[14px] [&_svg]:size-3.5";
const DEFAULT_SEPARATOR = <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m9 6 6 6-6 6" />
</svg>;
const BreadcrumbsContext = createContext(null);
function useBreadcrumbs(component) {
const context = useContext(BreadcrumbsContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Breadcrumbs>.`);
}
return context;
}
function Breadcrumbs({
label = "Breadcrumb",
separator,
className,
children,
...rest
}) {
return <BreadcrumbsContext.Provider value={{ separator: separator ?? DEFAULT_SEPARATOR }}>
<nav aria-label={label} className={className} {...rest}>
{children}
</nav>
</BreadcrumbsContext.Provider>;
}
function BreadcrumbList({ className, children, ...rest }) {
return <ol className={cx(LIST_CLASSES, className)} {...rest}>
{children}
</ol>;
}
function BreadcrumbItem({ className, children, ...rest }) {
return <li className={cx(ITEM_CLASSES, className)} {...rest}>
{children}
</li>;
}
function BreadcrumbLink({ href, icon, className, children, ...rest }) {
return <a href={href} className={cx(LINK_CLASSES, className)} {...rest}>
{icon ? <span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span> : null}
<span className="min-w-0">{children}</span>
</a>;
}
function BreadcrumbCurrent({ icon, className, children, ...rest }) {
return <span aria-current="page" className={cx(CURRENT_CLASSES, className)} {...rest}>
{icon ? <span aria-hidden="true" className={ICON_CLASSES}>
{icon}
</span> : null}
<span className="min-w-0">{children}</span>
</span>;
}
function BreadcrumbSeparator({ className, children, ...rest }) {
const context = useBreadcrumbs("BreadcrumbSeparator");
return <li
role="presentation"
aria-hidden="true"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
>
{children ?? context.separator}
</li>;
}
const ELLIPSIS_TRIGGER_CLASSES = "inline-flex h-5 min-w-6 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] px-1 text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] aria-expanded:bg-[var(--ds-color-surface-active)] aria-expanded:text-[var(--ds-color-foreground)] motion-reduce:transition-none";
const MENU_CLASSES = "absolute left-0 top-[calc(100%+4px)] z-40 min-w-[180px] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const MENU_ITEM_CLASSES = "flex w-full items-center gap-1.5 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-[13px] leading-5 text-[var(--ds-color-foreground)] no-underline transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
function BreadcrumbEllipsis({
items,
label = "Show hidden breadcrumb levels",
className
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const itemRefs = useRef([]);
const containerRef = useRef(null);
useEffect(() => {
if (!open) return;
function onDown(event) {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setOpen(false);
}
}
function onKey(event) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
if (event.key === "Tab") {
setOpen(false);
}
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function openMenu(focusIndex) {
setOpen(true);
setTimeout(() => itemRefs.current[focusIndex]?.focus(), 0);
}
function onTriggerKeyDown(event) {
if (event.key === "ArrowDown") {
event.preventDefault();
open ? itemRefs.current[0]?.focus() : openMenu(0);
} else if (event.key === "ArrowUp") {
event.preventDefault();
open ? itemRefs.current[items.length - 1]?.focus() : openMenu(items.length - 1);
}
}
function onItemKeyDown(event, index) {
const count = items.length;
if (event.key === "ArrowDown") {
event.preventDefault();
itemRefs.current[(index + 1) % count]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
itemRefs.current[(index - 1 + count) % count]?.focus();
} else if (event.key === "Home") {
event.preventDefault();
itemRefs.current[0]?.focus();
} else if (event.key === "End") {
event.preventDefault();
itemRefs.current[count - 1]?.focus();
}
}
return <li ref={containerRef} className={cx("relative inline-flex shrink-0 items-center", className)}>
<button
type="button"
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
onClick={() => open ? setOpen(false) : openMenu(0)}
onKeyDown={onTriggerKeyDown}
className={ELLIPSIS_TRIGGER_CLASSES}
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
className="size-3.5"
>
<circle cx="5" cy="12" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="19" cy="12" r="1.5" />
</svg>
</button>
{open ? <div role="menu" aria-label={label} className={MENU_CLASSES}>
{items.map((item, index) => <a
key={item.href}
ref={(el) => {
itemRefs.current[index] = el;
}}
role="menuitem"
tabIndex={-1}
href={item.href}
className={MENU_ITEM_CLASSES}
onClick={() => setOpen(false)}
onKeyDown={(event) => onItemKeyDown(event, index)}
>
{item.icon ? <span aria-hidden="true" className={ICON_CLASSES}>
{item.icon}
</span> : null}
<span className="min-w-0 flex-1">{item.label}</span>
</a>)}
</div> : null}
</li>;
}
export { Breadcrumbs, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbCurrent, BreadcrumbSeparator, BreadcrumbEllipsis };
export default Breadcrumbs; # Breadcrumbs Collapsed
Long breadcrumb paths with middle levels collapsed behind an accessible ellipsis disclosure — the hidden levels stay reachable as real links from a keyboard-operable menu.
## Usage
```tsx
import Breadcrumbs, {
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbCurrent,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from "./breadcrumbs-collapsed";
// Home / … / Components / Buttons
<Breadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbEllipsis
items={[
{ label: "Documentation", href: "/documentation" },
{ label: "React", href: "/documentation/react" },
]}
/>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/documentation/react/components">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Buttons</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumbs>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Breadcrumbs, {
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbCurrent,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from "./breadcrumbs-collapsed";
// Home / … / Components / Buttons
<Breadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbEllipsis
items={[
{ label: "Documentation", href: "/documentation" },
{ label: "React", href: "/documentation/react" },
]}
/>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/documentation/react/components">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Buttons</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumbs>
```
## Props
### `<Breadcrumbs>`
| Name | Type | Default | Description |
|---|---|---|---|
| `label` | `string` | `"Breadcrumb"` | Accessible label for the `<nav>` landmark. |
| `separator` | `ReactNode` | chevron icon | Default separator content for every `<BreadcrumbSeparator>` without children. |
| `className` | `string` | — | Extra classes on the `<nav>`. |
| `children` | `ReactNode` | — | `BreadcrumbList` composition. |
### `<BreadcrumbList>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<ol>`. |
| `children` | `ReactNode` | — | `BreadcrumbItem` + `BreadcrumbSeparator` elements. |
### `<BreadcrumbItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<li>`. |
| `children` | `ReactNode` | — | Usually one `BreadcrumbLink` or `BreadcrumbCurrent`. |
### `<BreadcrumbLink>`
| Name | Type | Default | Description |
|---|---|---|---|
| `href` | `string` (required) | — | Destination URL — rendered as a real anchor with normal browser navigation. |
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered `aria-hidden`). |
| `className` | `string` | — | Extra classes on the anchor. |
| `children` | `ReactNode` | — | Visible label. |
All native anchor attributes (`target`, `rel`, `aria-label`, `title`, …) are forwarded.
### `<BreadcrumbCurrent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered `aria-hidden`). |
| `className` | `string` | — | Extra classes on the span. |
| `children` | `ReactNode` | — | Visible label (rendered with `aria-current="page"`). |
### `<BreadcrumbSeparator>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the list item. |
| `children` | `ReactNode` | context `separator` | Custom separator content for this position only. |
### `<BreadcrumbEllipsis>`
| Name | Type | Default | Description |
|---|---|---|---|
| `items` | `BreadcrumbEllipsisItem[]` (required) | — | The collapsed levels, in path order. |
| `label` | `string` | `"Show hidden breadcrumb levels"` | Accessible name for the disclosure button. |
| `className` | `string` | — | Extra classes on the wrapping list item. |
`BreadcrumbEllipsisItem` = `{ label: ReactNode; href: string; icon?: ReactNode }`.
## Composition
Breadcrumbs is a compound component. Six primitives compose the pattern:
```tsx
<Breadcrumbs>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/documentation">Documentation</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Buttons</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumbs>
```
- `Breadcrumbs` — the root `<nav aria-label="Breadcrumb">` landmark. Provides the default `separator` to every separator through context.
- `BreadcrumbList` — the ordered list (`<ol>`). Wraps onto multiple lines instead of scrolling horizontally.
- `BreadcrumbItem` — one level of the trail (`<li>`).
- `BreadcrumbLink` — a real `<a href>` for navigable levels: normal browser navigation, optional leading `icon`.
- `BreadcrumbCurrent` — the current page: plain text with `aria-current="page"`, never a link.
- `BreadcrumbSeparator` — decorative structure between levels (`role="presentation"`, `aria-hidden`); renders its own `children`, else the `separator` given to `<Breadcrumbs>`, else the default chevron.
`<BreadcrumbEllipsis>` renders its own `<li>` and slots between separators where the removed levels would have been. Keep the first level (Home) and the last one or two levels visible; collapse the middle.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` / `ArrowDown` | Open the menu and focus the first item |
| `ArrowUp` (closed) | Open the menu and focus the last item |
| `ArrowDown` / `ArrowUp` (open) | Move between menu items (wraps around) |
| `Home` / `End` | Jump to the first / last menu item |
| `Enter` | Follow the focused menu link |
| `Escape` | Close the menu and return focus to the trigger |
| `Tab` | Close the menu and continue through the page |
## Accessibility
The structure follows the W3C Breadcrumb pattern: a `<nav aria-label="Breadcrumb">` landmark containing an ordered list (`<ol>`) whose last item is the current page marked with `aria-current="page"`.
- Every navigable level is a real `<a href>` — normal browser navigation, no click handlers faking links.
- The current page is plain text, not a link; assistive technology announces it as the current page.
- Separators are `aria-hidden` `role="presentation"` list items — never announced, never focusable.
- Icons are decorative (`aria-hidden`); the visible label always carries the accessible name.
The collapsed levels are never hidden with CSS alone — they are real anchor links inside a `role="menu"` disclosure. The trigger is a `<button aria-haspopup="menu" aria-expanded>` named "Show hidden breadcrumb levels" (override with `label`), reachable in the normal tab order, so keyboard and screen-reader users can reach every level. Escape closes and returns focus to the trigger.
## States
- **Link** — muted foreground; hover shifts to the foreground color with an underline.
- **Current page** — foreground color at medium weight; not interactive.
- **Separator** — muted decorative glyph, hidden from assistive technology.
- **Focus-visible** — `--ds-color-focus-ring` outline on links and menu triggers in both themes.
- **Ellipsis trigger** — compact `…` button; `surface-hover` on hover, `surface-active` + foreground while open (`aria-expanded` state, not color alone).
- **Menu items** — `surface-hover` on hover/focus; each is a real anchor link.
## Responsive Behavior
Collapsing is the preferred small-screen strategy: at 375px a five-level trail becomes Home / … / Components / Buttons, which fits without wrapping or scrolling while keeping every level reachable.
The list uses `flex-wrap` with a `min-w-0` flexible item per level, so long trails wrap onto multiple lines instead of forcing page-level horizontal scrolling. From 375px up, prefer intentional reduction over squeezing: collapse middle levels with `breadcrumbs-collapsed` and cap long labels with `breadcrumbs-max-width`.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `text-[var(--ds-color-muted-foreground)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This breadcrumb variant uses the semantic color, radius, typography, and motion tokens.
## Notes
Choose which levels to collapse from your route data (typically `items.slice(1, -2)`). The ellipsis menu preserves path order, top to bottom. 320 lines UTF-8 · LF · Spaces: 2
Continue browsing