← Componentes

Breadcrumb

Dos piezas, no cinco — el separador se intercala solo entre ítems (nunca lo escribes a mano) y acepta cualquier cosa renderizable, no un puñado de presets fijos; la página actual se decide sola: con href es un link, sin él es el texto final. Sin animación propia en la fila — un breadcrumb aparece en cada página y debe quedarse callado, no competir por atención. El único movimiento vive en el dropdown de los pasos ocultos.

Playground

Separator
<Breadcrumb>
  <BreadcrumbItem href="/">Inicio</BreadcrumbItem>
  <BreadcrumbItem href="/tienda">Tienda</BreadcrumbItem>
  <BreadcrumbItem>Kenza Studio</BreadcrumbItem>
</Breadcrumb>

Separadores

separator no es un enum cerrado — es ReactNode, así que un carácter ("/", el tono editorial por defecto; "›", más denso; "·", el más silencioso) o un ícono propio funcionan igual.

"/"

""

"·"

ícono propio

function DiamondIcon() {
  return (
    <svg viewBox="0 0 24 24" width="8" height="8" fill="currentColor">
      <path d="M12 2l6 10-6 10-6-10z" />
    </svg>
  );
}

<Breadcrumb separator={<DiamondIcon />}>{/* … */}</Breadcrumb>

Colapso automático (con dropdown)

maxItems — con más ítems que ese número, los del medio se colapsan solos en un . No eliges tú cuáles ocultar, igual que el max de AvatarGroup. A diferencia de un simple expandir-en-el-lugar, clickear abre un dropdown con los pasos ocultos — la fila no salta de tamaño, y puedes ir directo a cualquiera de ellos sin pasar por los demás. Cierra solo con Esc o clickeando afuera.

<Breadcrumb maxItems={4}>
  <BreadcrumbItem href="/">Inicio</BreadcrumbItem>
  <BreadcrumbItem href="/tienda">Tienda</BreadcrumbItem>
  <BreadcrumbItem href="/tienda/plantillas">Plantillas</BreadcrumbItem>
  <BreadcrumbItem href="/tienda/plantillas/editorial">Editorial</BreadcrumbItem>
  <BreadcrumbItem>Kenza Studio</BreadcrumbItem>
</Breadcrumb>
{/* 5 ítems, maxItems=4 → Inicio / … / Editorial / Kenza Studio — clickear "…" abre un dropdown con los pasos ocultos (Tienda, Plantillas) */}

Con next/link

asChild clona el hijo en vez de renderizar un <a> propio — mismo patrón que Dialog y Alert Dialog.

import Link from "next/link";

<BreadcrumbItem asChild>
  <Link href="/tienda">Tienda</Link>
</BreadcrumbItem>

Código

"use client";

import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDismiss } from "./lib/use-dismiss";

type BreadcrumbMode = "trail" | "menu";

const BreadcrumbContext = React.createContext<{ mode: BreadcrumbMode }>({ mode: "trail" });

const trailLinkClass =
  "rounded-sm text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary";

const menuLinkClass =
  "flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-foreground outline-none transition-colors duration-150 hover:bg-muted focus-visible:bg-muted";

export interface BreadcrumbProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /** cualquier cosa renderizable — un carácter ("/", "›", "·") o un ícono propio. Default: "/" */
  separator?: React.ReactNode;
  /** con más ítems que este número, los del medio se colapsan en un "…" con dropdown — no eliges tú cuáles ocultar */
  maxItems?: number;
  children?: React.ReactNode;
}

export const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
  ({ separator = "/", maxItems, className, children, ...props }, ref) => {
    const items = React.Children.toArray(children);
    const shouldCollapse = typeof maxItems === "number" && maxItems >= 2 && items.length > maxItems;

    let visible: React.ReactNode[] = items;
    if (shouldCollapse) {
      const tailCount = Math.max(1, maxItems - 2);
      const hidden = items.slice(1, items.length - tailCount);
      visible = [items[0], <BreadcrumbCollapsed key="__collapsed" items={hidden} />, ...items.slice(items.length - tailCount)];
    }

    // el separador es un nodo real intercalado entre ítems (no un ::before
    // por CSS) — así acepta cualquier ReactNode, no solo un puñado de
    // presets conocidos de antemano
    const withSeparators = visible.flatMap((item, i) =>
      i === 0
        ? [item]
        : [
            <li key={`sep-${i}`} aria-hidden="true" className="flex items-center text-muted-foreground/50">
              {separator}
            </li>,
            item,
          ],
    );

    return (
      <nav ref={ref} aria-label="Breadcrumb" className={className} {...props}>
        <ol className="flex flex-wrap items-center gap-1.5 text-sm">{withSeparators}</ol>
      </nav>
    );
  },
);
Breadcrumb.displayName = "Breadcrumb";

function BreadcrumbCollapsed({ items }: { items: React.ReactNode[] }) {
  const [open, setOpen] = React.useState(false);
  const rootRef = React.useRef<HTMLLIElement>(null);
  const menuCtx = React.useMemo(() => ({ mode: "menu" as const }), []);
  useDismiss(open, React.useCallback(() => setOpen(false), []), rootRef);

  return (
    <li ref={rootRef} className="relative flex items-center">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label="Mostrar pasos ocultos"
        className={trailLinkClass}
      >
        <span aria-hidden="true">…</span>
      </button>
      {/* sin portal — un breadcrumb vive arriba de la página, no adentro
          de un contenedor con overflow recortado, así que posicionarse
          absoluto contra sí mismo alcanza sin sumar la maquinaria de un
          popover completo */}
      <div
        role="menu"
        className={cn(
          "absolute left-0 top-full z-20 mt-2 min-w-[10rem] origin-top-left rounded-md border border-border bg-background p-1 transition-all duration-150",
          EASE,
          open ? "pointer-events-auto scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0",
        )}
      >
        <BreadcrumbContext.Provider value={menuCtx}>
          <ul className="flex flex-col">{items}</ul>
        </BreadcrumbContext.Provider>
      </div>
    </li>
  );
}

export interface BreadcrumbItemProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
  href?: string;
  /** clona el hijo en vez de renderizar <a> — para next/link u otro router */
  asChild?: boolean;
}

// con href: link. sin href: la página actual (texto, no clickeable,
// aria-current="page"). una sola prop decide cuál de las dos es, no dos
// componentes (BreadcrumbLink / BreadcrumbPage) que aprender. el mismo
// ítem se ve distinto si termina en la fila (trail) o en el dropdown de
// pasos ocultos (menu) — lee el contexto en vez de duplicarse en dos
// componentes separados.
export const BreadcrumbItem = React.forwardRef<HTMLAnchorElement, BreadcrumbItemProps>(
  ({ href, asChild = false, className, children, ...props }, ref) => {
    const { mode } = React.useContext(BreadcrumbContext);
    const isMenu = mode === "menu";
    const resolvedLinkClass = cn(isMenu ? menuLinkClass : trailLinkClass, EASE, className);

    let content: React.ReactNode;
    if (asChild && React.isValidElement(children)) {
      const child = children as React.ReactElement<{ className?: string }>;
      content = React.cloneElement(child, { className: cn(resolvedLinkClass, child.props.className) });
    } else if (href) {
      content = (
        <a ref={ref} href={href} className={resolvedLinkClass} {...props}>
          {children}
        </a>
      );
    } else {
      content = (
        <span
          aria-current="page"
          className={cn(isMenu ? "px-2 py-1.5 text-sm text-muted-foreground" : "font-medium text-foreground", className)}
        >
          {children}
        </span>
      );
    }

    if (isMenu) {
      return <li>{content}</li>;
    }

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

Código real de packages/ui/src/breadcrumb.tsx.

Uso

import { Breadcrumb, BreadcrumbItem } from "./components/ui/breadcrumb";

<Breadcrumb>
  <BreadcrumbItem href="/">Inicio</BreadcrumbItem>
  <BreadcrumbItem href="/tienda">Tienda</BreadcrumbItem>
  <BreadcrumbItem>Plantilla actual</BreadcrumbItem>
</Breadcrumb>

API

ComponentePropTipoDefault
BreadcrumbseparatorReactNode"/"
maxItemsnumber
BreadcrumbItemhrefstring
asChildbooleanfalse

Sin BreadcrumbLink / BreadcrumbPage / BreadcrumbSeparator aparte — href decide si es link o texto actual, y el separador es automático. Menos piezas que aprender.