# Breadcrumb

> Path navigation with separators and overflow collapse.

Category: Navigation. Docs: https://neonkit.heyaadi.com/components/breadcrumb

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/breadcrumb.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/breadcrumb`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

<Breadcrumb>
  <BreadcrumbList>
    <BreadcrumbItem>
      <BreadcrumbLink href="/projects">Projects</BreadcrumbLink>
    </BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem>
      <BreadcrumbPage>Settings</BreadcrumbPage>
    </BreadcrumbItem>
  </BreadcrumbList>
</Breadcrumb>
```

## Examples

### Page header

Links stay quiet; the current page is the one lit softly.

```tsx
import { House, KeyRound } from "@/components/ui/icons";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";

export default function BreadcrumbDemo() {
  return (
    <header className="grid w-full max-w-xl gap-4 rounded-xl border border-white/10 bg-white/[0.03] p-5">
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#lumen-labs">
              <House aria-hidden />
              Lumen Labs
            </BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbLink href="#projects">Projects</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbLink href="#aurora-web">aurora-web</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbPage>
              <KeyRound aria-hidden />
              Environment variables
            </BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </Breadcrumb>
      <div className="flex flex-wrap items-end justify-between gap-3">
        <div className="grid gap-1">
          <h3 className="text-lg font-medium text-white/90">Environment variables</h3>
          <p className="text-sm text-white/55">12 variables across Production, Preview and Development.</p>
        </div>
        <Button size="sm" variant="soft">
          Add variable
        </Button>
      </div>
    </header>
  );
}
```

### Collapsed trail

With maxItems, the first item and the last few stay and the middle folds into an ellipsis. Clicking it expands the trail in place.

```tsx
import { Fragment } from "react";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";

const trail = [
  { label: "Help Center", href: "#help" },
  { label: "Billing", href: "#billing" },
  { label: "Invoices", href: "#invoices" },
  { label: "Taxes", href: "#taxes" },
  { label: "EU VAT", href: "#eu-vat" },
];

export default function BreadcrumbCollapsed() {
  return (
    <Breadcrumb maxItems={3}>
      <BreadcrumbList>
        {trail.map((crumb) => (
          <Fragment key={crumb.href}>
            <BreadcrumbItem>
              <BreadcrumbLink href={crumb.href}>{crumb.label}</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
          </Fragment>
        ))}
        <BreadcrumbItem>
          <BreadcrumbPage>Reverse charge</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  );
}
```

### File path

Slash separators, a mono font and asChild buttons. Moving deeper or back up collapses the trail again once it outgrows maxItems.

```tsx
"use client";

import * as React from "react";
import { FileCode, Folder } from "@/components/ui/icons";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";

// null marks a file.
type Tree = { [name: string]: Tree | null };

const repo: Tree = {
  src: {
    components: {
      ui: { "breadcrumb.tsx": null, "pagination.tsx": null, "sidebar.tsx": null, "stepper.tsx": null },
      site: { "code-block.tsx": null, "docs-nav.tsx": null, "search.tsx": null },
    },
    lib: { "neon.ts": null, "utils.ts": null },
    registry: { "registry.json": null, "types.ts": null },
  },
  tests: { "registry.test.ts": null, "setup.ts": null },
  "package.json": null,
  "README.md": null,
};

function folderAt(path: string[]) {
  return path.reduce<Tree>((tree, name) => tree[name] ?? tree, repo);
}

export default function BreadcrumbFilePath() {
  const [path, setPath] = React.useState(["src", "components", "ui"]);
  const [file, setFile] = React.useState<string | null>("sidebar.tsx");

  const entries = Object.entries(folderAt(path)).sort(
    ([a, aTree], [b, bTree]) => Number(aTree === null) - Number(bTree === null) || a.localeCompare(b),
  );
  const crumbs = ["neonkit", ...path];

  function open(depth: number) {
    setPath(path.slice(0, depth));
    setFile(null);
  }

  return (
    <div className="w-full max-w-lg overflow-hidden rounded-xl border border-white/10 bg-white/[0.02]">
      <div className="border-b border-white/10 px-4 py-3">
        <Breadcrumb maxItems={4} aria-label="File path">
          <BreadcrumbList className="font-mono text-[13px] sm:gap-1.5">
            {crumbs.map((name, depth) => {
              const isLast = depth === crumbs.length - 1 && !file;
              return (
                <React.Fragment key={`${depth}-${name}`}>
                  <BreadcrumbItem>
                    {isLast ? (
                      <BreadcrumbPage>
                        <Folder aria-hidden />
                        {name}
                      </BreadcrumbPage>
                    ) : (
                      <BreadcrumbLink asChild>
                        <button type="button" className="cursor-pointer" onClick={() => open(depth)}>
                          {depth === 0 && <Folder aria-hidden />}
                          {name}
                        </button>
                      </BreadcrumbLink>
                    )}
                  </BreadcrumbItem>
                  {!isLast && <BreadcrumbSeparator>/</BreadcrumbSeparator>}
                </React.Fragment>
              );
            })}
            {file && (
              <BreadcrumbItem>
                <BreadcrumbPage>
                  <FileCode aria-hidden />
                  {file}
                </BreadcrumbPage>
              </BreadcrumbItem>
            )}
          </BreadcrumbList>
        </Breadcrumb>
      </div>
      <ul className="grid h-[188px] content-start gap-px overflow-y-auto p-2">
        {entries.map(([name, tree]) => {
          const selected = name === file;
          return (
            <li key={name}>
              <button
                type="button"
                onClick={() => {
                  if (tree) {
                    setPath([...path, name]);
                    setFile(null);
                  } else {
                    setFile(name);
                  }
                }}
                className={
                  "flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left font-mono text-[13px] outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] " +
                  (selected
                    ? "bg-[rgb(var(--neon)/0.1)] text-[rgb(var(--neon))]"
                    : "text-white/70 hover:bg-white/[0.04] hover:text-white/90")
                }
              >
                {tree ? (
                  <Folder aria-hidden className="size-3.5 shrink-0 text-white/40" />
                ) : (
                  <FileCode aria-hidden className="size-3.5 shrink-0 opacity-60" />
                )}
                {name}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

## API reference

### Breadcrumb

Renders a nav labelled "Breadcrumb" and accepts every native attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `maxItems` | `number` | — | Collapse the trail when it has more items than this (minimum 2). The first item and the last maxItems − 1 stay visible. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `aria-label` | `string` | `"Breadcrumb"` | Name of the navigation landmark. |

### BreadcrumbList

Renders an ol that wraps on narrow screens. Put BreadcrumbItem and BreadcrumbSeparator directly inside it (fragments are fine) so it can collapse them.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Classes for the list, e.g. a mono font for paths. |

### BreadcrumbItem

Renders an li around one link or the current page.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Classes for the list item. |

### BreadcrumbLink

Renders an a. Icons inside are sized to the text.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `string` | — | Where the crumb goes. |
| `asChild` | `boolean` | `false` | Merge the link styles onto the single child, such as your router's Link or a button. |

### BreadcrumbPage

The current page. Renders a span with aria-current="page".

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Classes for the span. |

### BreadcrumbSeparator

Renders a presentational li, hidden from screen readers.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `chevron` | Replaces the chevron, e.g. a slash. |

### BreadcrumbEllipsis

The button that maxItems renders. Exported for building your own overflow, e.g. as a menu trigger.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Show more"` | Accessible name for the icon button. |
| `children` | `ReactNode` | `ellipsis icon` | Replaces the icon. |

## Accessibility

- The trail is an ordered list inside a nav landmark named "Breadcrumb".
- The current page carries aria-current="page" and is not a link.
- Separators are presentational and hidden from screen readers, so the list reads as its links only.
- The collapse button is named for what it reveals, such as "Show 3 more items". After expanding, focus moves to the first revealed link.
- Revealed items slide in with a short stagger; with reduced motion they appear at once.

## Source

`components/ui/breadcrumb.tsx`

```tsx
"use client";

import * as React from "react";
import { ChevronRight, Ellipsis } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/** The single element in `node`. Server Components can pass it as a lazy reference, which Children resolves. */
function resolveElement<P = Record<string, unknown>>(node: React.ReactNode): React.ReactElement<P> | null {
  const [child] = React.Children.toArray(node);
  return React.isValidElement<P>(child) ? child : null;
}

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

// Revealed items slide in a few pixels from the ellipsis, one after another.
const keyframes = `@keyframes nk-breadcrumb-reveal{from{opacity:0;transform:translateX(-4px)}}`;
const revealClass =
  "motion-safe:[animation:nk-breadcrumb-reveal_220ms_cubic-bezier(0.23,1,0.32,1)_both]";

const BreadcrumbContext = React.createContext<{ maxItems?: number }>({});

type AnyRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: AnyRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.RefObject<T | null>).current = value;
}

// React 19 moved `ref` into props; reading `element.ref` there logs a warning.
function getElementRef<T>(element: React.ReactElement): AnyRef<T> {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<T> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<T> }).ref;
}

const LAZY = Symbol.for("react.lazy");

type LazyType = { $$typeof: symbol; _payload: unknown; _init: (payload: unknown) => unknown };

/**
 * True when `node` is an element of `component`. Children passed down from a Server Component
 * can arrive as lazy references to the client module, so those are resolved first. This file is
 * already loaded by the time it runs, so resolving never suspends.
 */
function isElementOf(node: React.ReactNode, component: unknown) {
  if (!React.isValidElement(node)) return false;
  let type: unknown = node.type;
  if (typeof type === "object" && type !== null && (type as LazyType).$$typeof === LAZY) {
    try {
      type = (type as LazyType)._init((type as LazyType)._payload);
    } catch {
      return false;
    }
  }
  return type === component;
}

/** Flattens fragments so `items.map(item => <>…</>)` collapses like plain children. */
function flatten(children: React.ReactNode, prefix = ""): React.ReactNode[] {
  return React.Children.toArray(children).flatMap((child) => {
    if (!React.isValidElement<{ children?: React.ReactNode }>(child)) return [child];
    const key = `${prefix}${child.key ?? ""}`;
    if (child.type === React.Fragment) return flatten(child.props.children, key);
    return [prefix ? React.cloneElement(child, { key }) : child];
  });
}

export interface BreadcrumbProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /**
   * Collapse the middle of the trail when it has more items than this. The first item and the
   * last `maxItems - 1` stay; the rest fold into an ellipsis button that expands inline.
   */
  maxItems?: number;
}

const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
  ({ className, style, color, maxItems, children, "aria-label": ariaLabel, ...props }, ref) => {
    const context = React.useMemo(() => ({ maxItems }), [maxItems]);

    return (
      <BreadcrumbContext.Provider value={context}>
        <nav
          ref={ref}
          aria-label={ariaLabel ?? "Breadcrumb"}
          style={neonVars(color, style)}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", className)}
          {...props}
        >
          {maxItems != null && <style>{keyframes}</style>}
          {children}
        </nav>
      </BreadcrumbContext.Provider>
    );
  },
);
Breadcrumb.displayName = "Breadcrumb";

const BreadcrumbList = React.forwardRef<HTMLOListElement, React.OlHTMLAttributes<HTMLOListElement>>(
  ({ className, children, ...props }, ref) => {
    const { maxItems } = React.useContext(BreadcrumbContext);
    const listRef = React.useRef<HTMLOListElement>(null);
    const mergedRef = React.useCallback(
      (node: HTMLOListElement | null) => {
        setRef(ref, node);
        listRef.current = node;
      },
      [ref],
    );
    const focusRevealed = React.useRef(false);
    // Remember which trail was expanded, so navigating to a new path collapses again.
    const [expandedFor, setExpandedFor] = React.useState<string | null>(null);

    const nodes = flatten(children);
    const itemIndexes = nodes.flatMap((node, index) => (isElementOf(node, BreadcrumbItem) ? [index] : []));
    const count = itemIndexes.length;
    const trail = itemIndexes.map((index) => (nodes[index] as React.ReactElement).key).join("|");
    const limit = maxItems == null ? Infinity : Math.max(2, Math.floor(maxItems));
    const overflowing = count > limit;
    const collapsed = overflowing && expandedFor !== trail;

    // Everything between the first item and the trailing items folds away.
    const headEnd = overflowing ? itemIndexes[0] : -1;
    const tailStart = overflowing ? itemIndexes[count - (limit - 1)] : -1;
    const hidden = overflowing ? nodes.slice(headEnd + 1, tailStart) : [];
    const isSeparator = (node: React.ReactNode) => isElementOf(node, BreadcrumbSeparator);
    const leadingSeparator = isSeparator(hidden[0]) ? hidden[0] : null;
    const trailingSeparator =
      hidden.length > 1 && isSeparator(hidden[hidden.length - 1]) ? hidden[hidden.length - 1] : null;

    React.useEffect(() => {
      if (collapsed || !focusRevealed.current) return;
      focusRevealed.current = false;
      listRef.current
        ?.querySelector<HTMLElement>("[data-revealed] a, [data-revealed] button, [data-revealed] [tabindex]")
        ?.focus();
    }, [collapsed]);

    let content: React.ReactNode[] = nodes;

    if (collapsed) {
      const hiddenItems = count - limit;
      content = [
        ...nodes.slice(0, headEnd + 1),
        leadingSeparator,
        <BreadcrumbItem key="nk-breadcrumb-ellipsis">
          <BreadcrumbEllipsis
            aria-label={`Show ${hiddenItems} more ${hiddenItems === 1 ? "item" : "items"}`}
            onClick={() => {
              focusRevealed.current = true;
              setExpandedFor(trail);
            }}
          />
        </BreadcrumbItem>,
        trailingSeparator &&
          React.cloneElement(trailingSeparator as React.ReactElement, { key: "nk-breadcrumb-ellipsis-separator" }),
        ...nodes.slice(tailStart),
      ];
    } else if (overflowing) {
      let step = 0;
      content = nodes.map((node, index) => {
        const revealed = index > headEnd && index < tailStart && node !== leadingSeparator;
        if (!revealed || !React.isValidElement<React.HTMLAttributes<HTMLElement>>(node)) return node;
        return React.cloneElement(node, {
          "data-revealed": "",
          className: cn(node.props.className, revealClass),
          style: { ...node.props.style, animationDelay: `${step++ * 28}ms` },
        } as React.HTMLAttributes<HTMLElement>);
      });
    }

    return (
      <ol
        ref={mergedRef}
        className={cn(
          "flex flex-wrap items-center gap-1.5 break-words text-sm leading-5 text-white/55 sm:gap-2",
          className,
        )}
        {...props}
      >
        {content}
      </ol>
    );
  },
);
BreadcrumbList.displayName = "BreadcrumbList";

const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.LiHTMLAttributes<HTMLLIElement>>(
  ({ className, ...props }, ref) => (
    <li ref={ref} className={cn("inline-flex min-w-0 items-center gap-1.5", className)} {...props} />
  ),
);
BreadcrumbItem.displayName = "BreadcrumbItem";

export interface BreadcrumbLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  /** Merge the link styles onto the single child element, such as your router's Link. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
  (raw, ref) => {
    const { className, asChild = false, children, ...props } = fromRenderProp(raw);
    const child =
      asChild ? resolveElement<React.HTMLAttributes<HTMLElement>>(children) : null;
    const childRef = child ? getElementRef<HTMLAnchorElement>(child) : undefined;
    const mergedRef = React.useCallback(
      (node: HTMLAnchorElement | null) => {
        setRef(ref, node);
        setRef(childRef, node);
      },
      [ref, childRef],
    );

    const classes = cn(
      "inline-flex min-w-0 items-center gap-1.5 rounded-md text-white/55 outline-none",
      "transition-[color,text-shadow] duration-150 ease-out hover:text-white/90",
      "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
      "[&_svg]:size-3.5 [&_svg]:shrink-0",
      className,
    );

    if (child) {
      return React.cloneElement(child, {
        ...props,
        ...child.props,
        className: cn(classes, child.props.className),
        onClick: (event: React.MouseEvent<HTMLElement>) => {
          child.props.onClick?.(event);
          if (!event.defaultPrevented) props.onClick?.(event as React.MouseEvent<HTMLAnchorElement>);
        },
        ref: mergedRef,
      } as React.HTMLAttributes<HTMLElement>);
    }

    return (
      <a ref={ref} className={classes} {...props}>
        {children}
      </a>
    );
  },
);
BreadcrumbLink.displayName = "BreadcrumbLink";

const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
  ({ className, ...props }, ref) => (
    <span
      ref={ref}
      aria-current="page"
      className={cn(
        "inline-flex min-w-0 items-center gap-1.5 font-medium text-[rgb(var(--neon))]",
        "[text-shadow:0_0_12px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))] [&_svg]:size-3.5 [&_svg]:shrink-0",
        "[&_svg]:[filter:drop-shadow(0_0_4px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))))]",
        className,
      )}
      {...props}
    />
  ),
);
BreadcrumbPage.displayName = "BreadcrumbPage";

const BreadcrumbSeparator = React.forwardRef<HTMLLIElement, React.LiHTMLAttributes<HTMLLIElement>>(
  ({ className, children, ...props }, ref) => (
    <li
      ref={ref}
      role="presentation"
      aria-hidden="true"
      className={cn("inline-flex select-none items-center text-white/25 [&_svg]:size-3.5", className)}
      {...props}
    >
      {children ?? <ChevronRight />}
    </li>
  ),
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";

const BreadcrumbEllipsis = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
  ({ className, children, ...props }, ref) => (
    <button
      ref={ref}
      type="button"
      aria-label="Show more"
      className={cn(
        "inline-flex h-6 w-7 cursor-pointer items-center justify-center rounded-md text-white/50 outline-none",
        "transition-[background-color,color,box-shadow,transform] duration-150 ease-out active:scale-[0.97]",
        "hover:bg-[rgb(var(--neon)/0.1)] hover:text-[rgb(var(--neon))] hover:shadow-[0_0_12px_rgb(var(--neon)/CALC(0.2*var(--neon-glow,1)))]",
        "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
        "[&_svg]:size-4",
        className,
      )}
      {...props}
    >
      {children ?? <Ellipsis />}
    </button>
  ),
);
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";

export {
  Breadcrumb,
  BreadcrumbList,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbPage,
  BreadcrumbSeparator,
  BreadcrumbEllipsis,
};
```
