Breadcrumb
A navigation trail for the current page.
Preview
Loading...
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/Breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/DropdownMenu";
const breadcrumbFocus =
"small inline-flex items-center border-0 bg-transparent p-0 text-gray-11 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-8";
function BreadcrumbMore({ items }: { items: string[] }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Show more breadcrumbs"
className={breadcrumbFocus}
>
<BreadcrumbEllipsis />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{items.map((item) => (
<DropdownMenuItem key={item}>{item}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const BreadcrumbDemo = () => {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#home">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbMore items={["Docs", "Themes"]} />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#components">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/breadcrumbManual
Install the following dependencies:
pnpm install @radix-ui/react-icons @radix-ui/react-slotCopy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the Breadcrumb component into your components/ui/Breadcrumb.tsx file.
"use client";
/**
* @description A navigation trail for the current page.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { cn } from "@/utils/class-names";
const breadcrumbFocus =
"rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-8";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="Breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
type Crumb = {
item: React.ReactElement;
separator: React.ReactElement | null;
};
function isElement(node: React.ReactNode): node is React.ReactElement {
return React.isValidElement(node);
}
function isSeparator(node: React.ReactNode): node is React.ReactElement {
return isElement(node) && node.type === BreadcrumbSeparator;
}
function containsEllipsis(node: React.ReactNode): boolean {
if (!isElement(node)) return false;
if (node.type === BreadcrumbEllipsis) return true;
const children = (node.props as { children?: React.ReactNode }).children;
return React.Children.toArray(children).some(containsEllipsis);
}
function readCrumbs(children: React.ReactNode): Crumb[] {
const nodes = React.Children.toArray(children);
const crumbs: Crumb[] = [];
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (!isElement(node) || isSeparator(node)) continue;
const next = nodes[index + 1];
crumbs.push({
item: node,
separator: isSeparator(next) ? next : null,
});
}
return crumbs;
}
function clonePart(
node: React.ReactElement,
props: React.ComponentProps<"li"> & { key?: React.Key },
) {
const { key, ...rest } = props;
const current = node.props as { className?: string };
return React.cloneElement(node, {
...rest,
key,
className: cn(current.className, rest.className),
});
}
function separatorNode(
source: React.ReactElement | null,
props?: React.ComponentProps<"li"> & { key?: React.Key },
) {
if (!source) return <BreadcrumbSeparator {...props} />;
return clonePart(source, props ?? {});
}
function BreadcrumbList({
className,
maxItems,
ellipsis,
children,
...props
}: React.ComponentProps<"ol"> & {
/** Collapse to first › ellipsis › current page when the trail is longer than this. */
maxItems?: number;
/** Replaces the generated ellipsis. Use a labeled dropdown trigger for the hidden crumbs. */
ellipsis?: React.ReactNode;
}) {
const [expanded, setExpanded] = React.useState(false);
const crumbs = readCrumbs(children);
const forceCollapse =
typeof maxItems === "number" &&
maxItems >= 1 &&
crumbs.length > maxItems &&
crumbs.length >= 2;
const responsiveCollapse =
!forceCollapse &&
!expanded &&
crumbs.length > 2 &&
!containsEllipsis(children);
const ellipsisNode =
ellipsis ??
(forceCollapse ? (
<BreadcrumbEllipsis />
) : (
<button
type="button"
className={cn(
"small inline-flex items-center border-0 bg-transparent p-0 text-gray-11",
breadcrumbFocus,
)}
aria-expanded={expanded}
aria-label="Show more breadcrumbs"
onClick={() => setExpanded(true)}
>
<BreadcrumbEllipsis />
</button>
));
let content: React.ReactNode = children;
if ((forceCollapse || responsiveCollapse) && crumbs.length >= 2) {
const first = crumbs[0];
const last = crumbs[crumbs.length - 1];
const middle = crumbs.slice(1, -1);
content = (
<>
{first.item}
{separatorNode(first.separator)}
<BreadcrumbItem data-breadcrumb="ellipsis">
{ellipsisNode}
</BreadcrumbItem>
{separatorNode(first.separator, {
key: "breadcrumb-after-ellipsis",
...(responsiveCollapse ? { "data-breadcrumb": "ellipsis" as const } : {}),
})}
{responsiveCollapse
? middle.map((crumb) => (
<React.Fragment key={crumb.item.key}>
{clonePart(crumb.item, { "data-breadcrumb": "middle" })}
{crumb.separator
? separatorNode(crumb.separator, {
"data-breadcrumb": "middle",
})
: null}
</React.Fragment>
))
: null}
{last.item}
</>
);
}
return (
<ol
data-slot="breadcrumb-list"
data-collapse={responsiveCollapse ? "sm" : undefined}
data-expanded={expanded ? "true" : undefined}
className={cn(
"small flex flex-wrap items-center gap-0.5 text-gray-11",
className,
)}
{...props}
>
{content}
</ol>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild = false,
className,
...props
}: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn(
"small text-gray-11 transition-colors hover:text-gray-12 hover:underline underline-offset-2",
breadcrumbFocus,
className,
)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
aria-current="page"
className={cn("small font-semibold text-gray-12", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("inline-flex items-center", className)}
{...props}
>
{children ?? <ChevronRightIcon className="icon rtl:rotate-180" />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"inline-flex size-2 items-center justify-center",
className,
)}
{...props}
>
<DotsHorizontalIcon className="icon" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};Usage
The ellipsis sits in a DropdownMenu trigger named “Show more breadcrumbs”. BreadcrumbMore above is that trigger. Pass it as ellipsis when maxItems or the narrow layout hides the middle crumbs.
Custom separator
Loading...
export const BreadcrumbDemoCustomSeparator = () => {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#home">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href="#docs">Docs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};Collapsed
Loading...
export const BreadcrumbDemoCollapsed = () => {
return (
<Breadcrumb>
<BreadcrumbList
maxItems={2}
ellipsis={<BreadcrumbMore items={["Docs", "Components"]} />}
>
<BreadcrumbItem>
<BreadcrumbLink href="#home">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#docs">Docs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#components">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};Mobile
Loading...
export const BreadcrumbDemoMobile = () => {
return (
<div className="smish">
<Breadcrumb>
<BreadcrumbList
ellipsis={
<BreadcrumbMore items={["Docs", "Components", "Navigation"]} />
}
>
<BreadcrumbItem>
<BreadcrumbLink href="#home">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#docs">Docs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#components">Components</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#navigation">Navigation</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Breadcrumb</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
);
};