WUWensity UI

Breadcrumb

A page trail with separators, collapsing and current-page state.

Installation

npx shadcn@latest add https://layro.dev/r/wensity/breadcrumb.json

Or, with @layro in your components.json:

npx shadcn@latest add @layro/wensity/breadcrumb

Code

"use client";

/**
 * Breadcrumb — custom (Phase 5). Reduced-motion gated expand/dropdown motion.
 * Dropdown overflow uses overlay + menu-item tokens. RTL: prefer IconChevronLeft
 * when `dir="rtl"` is set on an ancestor (consumers should flip chevrons).
 */

import * as React from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import {
  IconChevronRight,
  IconDots,
  IconSlash,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";

/** Motion tiers, the same speeds as the --primitive-motion-* tokens. */
const SPRING = {
  fast: { type: "spring", duration: 0.08, bounce: 0 },
  moderate: { type: "spring", duration: 0.16, bounce: 0 },
  slow: { type: "spring", duration: 0.24, bounce: 0 },
} as const;

/* ─── Types ─────────────────────────────────────────────────── */

export type BreadcrumbVariant =
  | "default"
  | "with-icons"
  | "collapsed"
  | "dropdown"
  | "custom-separator";

export interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
  variant?: BreadcrumbVariant;
  /** Custom separator node — used by all variants but most visible in `custom-separator`. */
  separator?: React.ReactNode;
  /** For collapsed/dropdown variants: maximum visible items before collapsing (default 3). */
  maxItems?: number;
}

export interface BreadcrumbItemProps
  extends React.HTMLAttributes<HTMLLIElement> {
  /** Link href — omit for the current (last) page. */
  href?: string;
  /** Optional leading icon. */
  icon?: React.ReactNode;
  /** Mark as the current page — renders as static text. */
  isCurrentPage?: boolean;
}

export interface BreadcrumbSeparatorProps
  extends React.HTMLAttributes<HTMLLIElement> {
  children?: React.ReactNode;
}

/* ─── Context ───────────────────────────────────────────────── */

type BreadcrumbContextValue = {
  variant: BreadcrumbVariant;
  separator: React.ReactNode;
};

const BreadcrumbContext = React.createContext<BreadcrumbContextValue>({
  variant: "default",
  separator: null,
});

/* ─── Easing ────────────────────────────────────────────────── */


/* ─── Default separators per variant ────────────────────────── */

function getDefaultSeparator(variant: BreadcrumbVariant): React.ReactNode {
  switch (variant) {
    case "with-icons":
      return <IconChevronRight className="size-3.5 text-[var(--muted-foreground)] opacity-50" />;
    case "custom-separator":
      return (
        <span className="text-[var(--muted-foreground)] opacity-40 text-xs select-none">
          →
        </span>
      );
    default:
      return <IconSlash className="size-3 text-[var(--muted-foreground)] opacity-40 -rotate-[20deg]" />;
  }
}

/* ─── <Breadcrumb> ──────────────────────────────────────────── */

export const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
  (
    {
      variant = "default",
      separator: customSeparator,
      maxItems = 3,
      className,
      children,
      ...props
    },
    ref,
  ) => {
    const separator = customSeparator ?? getDefaultSeparator(variant);
    const ctx = React.useMemo<BreadcrumbContextValue>(
      () => ({ variant, separator }),
      [variant, separator],
    );

    const items = React.Children.toArray(children);
    const maxVisibleItems = Number.isFinite(maxItems)
      ? Math.max(3, Math.floor(maxItems))
      : 3;

    /* ── Collapsed / Dropdown logic ──────────────────────────── */
    if (
      (variant === "collapsed" || variant === "dropdown") &&
      items.length > maxVisibleItems
    ) {
      const visibleMiddleCount = maxVisibleItems - 3;
      const leadingMiddleCount = Math.floor(visibleMiddleCount / 2);
      const trailingMiddleCount = visibleMiddleCount - leadingMiddleCount;
      const hiddenStartIndex = 1 + leadingMiddleCount;
      const hiddenEndIndex = items.length - 1 - trailingMiddleCount;
      const leadingItems = items.slice(0, hiddenStartIndex);
      const hiddenItems = items.slice(hiddenStartIndex, hiddenEndIndex);
      const trailingItems = items.slice(hiddenEndIndex);

      return (
        <BreadcrumbContext.Provider value={ctx}>
          <nav data-wensity-primitive="" ref={ref} aria-label="breadcrumb" className={className} {...props}>
            <ol className="flex items-center gap-1.5">
              {leadingItems.map((child, i) => (
                <React.Fragment key={`leading-${i}`}>
                  {i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
                  <BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
                </React.Fragment>
              ))}
              <BreadcrumbSeparatorInternal separator={separator} />

              {/* Collapsed middle */}
              <CollapsedMiddle
                variant={variant}
                items={hiddenItems}
                separator={separator}
              />
              <BreadcrumbSeparatorInternal separator={separator} />

              {trailingItems.map((child, i) => (
                <React.Fragment key={`trailing-${i}`}>
                  {i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
                  <BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
                </React.Fragment>
              ))}
            </ol>
          </nav>
        </BreadcrumbContext.Provider>
      );
    }

    /* ── Standard rendering ──────────────────────────────────── */
    return (
      <BreadcrumbContext.Provider value={ctx}>
        <nav data-wensity-primitive="" ref={ref} aria-label="breadcrumb" className={className} {...props}>
          <ol className="flex flex-wrap items-center gap-1.5">
            {items.map((child, i) => (
              <React.Fragment key={i}>
                {i > 0 && <BreadcrumbSeparatorInternal separator={separator} />}
                <BreadcrumbItemWrapper>{child}</BreadcrumbItemWrapper>
              </React.Fragment>
            ))}
          </ol>
        </nav>
      </BreadcrumbContext.Provider>
    );
  },
);
Breadcrumb.displayName = "Breadcrumb";

/* ─── Internal separator ────────────────────────────────────── */

function BreadcrumbSeparatorInternal({
  separator,
}: {
  separator: React.ReactNode;
}) {
  return (
    <li
      role="presentation"
      aria-hidden
      className="flex items-center select-none [&_svg]:size-3"
    >
      {separator}
    </li>
  );
}

/* ─── Item wrapper (thin pass-through for ol semantics) ────── */

function BreadcrumbItemWrapper({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}

/* ─── <BreadcrumbItem> ──────────────────────────────────────── */

const linkBase = cn(
  "inline-flex items-center gap-1.5 text-sm font-medium leading-none tracking-[-0.01em]",
  "transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
  "motion-reduce:transition-none",
  "outline-none select-none",
  "focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)] focus-visible:rounded-[var(--primitive-radius-control-sm)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-3.5",
);

const ghostIconButtonClass = cn(
  "inline-flex size-7 items-center justify-center rounded-[var(--primitive-radius-item)]",
  "text-[var(--muted-foreground)]",
  "transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
  "motion-reduce:transition-none",
  "hover:text-[var(--foreground)] hover:bg-[var(--primitive-surface-hover)]",
  "focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
  "outline-none",
);

export const BreadcrumbItem = React.forwardRef<
  HTMLLIElement,
  BreadcrumbItemProps
>(({ href, icon, isCurrentPage, className, children, ...props }, ref) => {
  const { variant } = React.useContext(BreadcrumbContext);
  const content = (
    <BreadcrumbItemContent
      href={href}
      icon={icon}
      isCurrentPage={isCurrentPage}
      className={className}
      variant={variant}
    >
      {children}
    </BreadcrumbItemContent>
  );

  return (
    <li ref={ref} className="flex items-center" {...props}>
      {content}
    </li>
  );
});
BreadcrumbItem.displayName = "BreadcrumbItem";

function BreadcrumbItemContent({
  href,
  icon,
  isCurrentPage,
  className,
  children,
  variant,
}: BreadcrumbItemProps & { variant: BreadcrumbVariant }) {
  const showIcon = variant === "with-icons" && icon;

  if (isCurrentPage || !href) {
    return (
      <span
        aria-current={isCurrentPage ? "page" : undefined}
        className={cn(
          linkBase,
          "text-[var(--foreground)] cursor-default",
          className,
        )}
      >
        {showIcon && icon}
        {children}
      </span>
    );
  }

  return (
    <a
      href={href}
      onClick={(e) => {
        if (href === "#") {
          e.preventDefault();
        }
      }}
      className={cn(
        linkBase,
        "text-[var(--muted-foreground)]",
        "hover:text-[var(--foreground)]",
        "group/crumb relative",
        className,
      )}
    >
      {showIcon && icon}
      <span className="relative">
        {children}
        {/* Underline slide-in from left on hover */}
        <span
          className={cn(
            "absolute -bottom-0.5 left-0 h-px w-full origin-left scale-x-0",
            "bg-[var(--foreground)] opacity-40",
            "transition-transform duration-[var(--primitive-motion-moderate)] ease-[cubic-bezier(0.23,1,0.32,1)]",
            "group-hover/crumb:scale-x-100",
          )}
        />
      </span>
    </a>
  );
}

/* ─── <BreadcrumbSeparator> (public, for custom-separator) ─── */

export const BreadcrumbSeparator = React.forwardRef<
  HTMLLIElement,
  BreadcrumbSeparatorProps
>(({ className, children, ...props }, ref) => {
  const { separator } = React.useContext(BreadcrumbContext);
  return (
    <li
      ref={ref}
      role="presentation"
      aria-hidden
      className={cn("flex items-center select-none [&_svg]:size-3", className)}
      {...props}
    >
      {children ?? separator}
    </li>
  );
});
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";

/* ─── Collapsed middle items ────────────────────────────────── */

function CollapsedMiddle({
  variant,
  items,
  separator,
}: {
  variant: BreadcrumbVariant;
  items: React.ReactNode[];
  separator: React.ReactNode;
}) {
  const reduceMotion = useReducedMotion();
  const [expanded, setExpanded] = React.useState(false);
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
  const dropdownRef = React.useRef<HTMLLIElement>(null);

  /* Close dropdown on outside click */
  React.useEffect(() => {
    if (!dropdownOpen) return;
    function handleClick(e: MouseEvent) {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(e.target as Node)
      ) {
        setDropdownOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [dropdownOpen]);

  /* ── Collapsed variant: expand inline ───── */
  if (variant === "collapsed") {
    if (expanded) {
      return (
        <>
          {items.map((child, i) => (
            <React.Fragment key={i}>
              <AnimatedBreadcrumbItem child={child} index={i} />
              {i < items.length - 1 && (
                <motion.li
                  role="presentation"
                  aria-hidden
                  initial={reduceMotion ? false : { opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { ...SPRING.fast, delay: i * 0.04 + 0.05 }
                  }
                  className="flex items-center select-none [&_svg]:size-3"
                >
                  {separator}
                </motion.li>
              )}
            </React.Fragment>
          ))}
        </>
      );
    }

    return (
      <li className="flex items-center">
        <button
          type="button"
          aria-label={`Show ${items.length} more items`}
          onClick={() => setExpanded(true)}
          className={ghostIconButtonClass}
        >
          <IconDots className="size-3.5" />
        </button>
      </li>
    );
  }

  /* ── Dropdown variant: floating menu ───── */
  return (
    <li className="relative flex items-center" ref={dropdownRef}>
      <button
        type="button"
        aria-label={`Show ${items.length} more items`}
        aria-expanded={dropdownOpen}
        onClick={() => setDropdownOpen((v) => !v)}
        className={cn(
          ghostIconButtonClass,
          dropdownOpen &&
            "text-[var(--foreground)] bg-[var(--primitive-surface-selected)]",
        )}
      >
        <IconDots className="size-3.5" />
      </button>

      <AnimatePresence>
        {dropdownOpen && (
          <motion.div
            data-surface="overlay"
            initial={reduceMotion ? false : { opacity: 0, scale: 0.96, y: 4 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={reduceMotion ? undefined : { opacity: 0, scale: 0.96, y: 4 }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : SPRING.fast
            }
            className={cn(
              "absolute bottom-full left-1/2 z-[var(--primitive-z-popover)] mb-2 -translate-x-1/2",
              "min-w-[140px] rounded-[var(--primitive-radius-popup)] p-1",
            )}
            style={{ transformOrigin: "bottom center" }}
          >
            {items.map((child, i) => {
              /* Extract text/href from BreadcrumbItem children */
              const childEl = child as React.ReactElement<BreadcrumbItemProps>;
              const href = childEl?.props?.href;
              const label = childEl?.props?.children;
              const icon = childEl?.props?.icon;

              return (
                <a
                  key={i}
                  href={href ?? "#"}
                  onClick={(e) => {
                    setDropdownOpen(false);
                    if (!href || href === "#") {
                      e.preventDefault();
                    }
                  }}
                  className={cn(
                    "flex items-center gap-2.5 rounded-[var(--primitive-radius-item)] px-2.5 py-2 text-sm font-medium",
                    "text-[var(--muted-foreground)]",
                    "transition-colors duration-[var(--primitive-motion-fast)] ease-[var(--primitive-ease,cubic-bezier(0.23,1,0.32,1))]",
                    "motion-reduce:transition-none",
                    "hover:text-[var(--foreground)] hover:bg-[var(--primitive-surface-selected)]",
                    "outline-none",
                    "[&_svg]:size-3.5 [&_svg]:shrink-0",
                  )}
                >
                  {icon}
                  {label}
                </a>
              );
            })}
          </motion.div>
        )}
      </AnimatePresence>
    </li>
  );
}

function AnimatedBreadcrumbItem({
  child,
  index,
}: {
  child: React.ReactNode;
  index: number;
}) {
  const { variant } = React.useContext(BreadcrumbContext);
  const reduceMotion = useReducedMotion();
  const transition = reduceMotion
    ? { duration: 0 }
    : { ...SPRING.fast, delay: index * 0.04 };

  if (!React.isValidElement<BreadcrumbItemProps>(child)) {
    return (
      <li className="flex items-center">
        <motion.div
          initial={reduceMotion ? false : { opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          transition={transition}
          className="flex items-center"
        >
          {child}
        </motion.div>
      </li>
    );
  }

  const { href, icon, isCurrentPage, className, children, ...props } =
    child.props;

  return (
    <li {...props} className="flex items-center">
      <motion.div
        initial={reduceMotion ? false : { opacity: 0, y: 4 }}
        animate={{ opacity: 1, y: 0 }}
        transition={transition}
        className="flex items-center"
      >
        <BreadcrumbItemContent
          href={href}
          icon={icon}
          isCurrentPage={isCurrentPage}
          className={className}
          variant={variant}
        >
          {children}
        </BreadcrumbItemContent>
      </motion.div>
    </li>
  );
}

Versions

  1. 1.0.0 · current

    Oct 27, 2026

    First release.

Similar components