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
<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
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Breadcrumb | separator | ReactNode | "/" |
| maxItems | number | — | |
| BreadcrumbItem | href | string | — |
| asChild | boolean | false |
Sin BreadcrumbLink / BreadcrumbPage / BreadcrumbSeparator aparte — href decide si es link o texto actual, y el separador es automático. Menos piezas que aprender.