Aura Design System

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/breadcrumb

Manual

Install the following dependencies:

pnpm install @radix-ui/react-icons @radix-ui/react-slot

Copy 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>
  );
};