Migas de Pan
Un camino horizontal de enlaces que muestran la ubicación actual del usuario en la jerarquía de navegación, permitiendo recorrer rápidamente de vuelta a páginas superiores.
Casos de Uso
- Páginas de productos de comercio electrónico (Inicio > Categoría > Subcategoría > Producto)
- Sitios de documentación con secciones anidadas
- Paneles de administración con configuraciones multinivel
- Interfaces de gestión de archivos que muestran rutas de carpetas
- Formularios de múltiples pasos indicando el paso actual
- Resultados de búsqueda mostrando la ruta de categoría de cada resultado
- Editores de contenido CMS con jerarquías de página anidadas
Implementación más Simple
"use client";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-gray-500">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-2">
{index > 0 && <span aria-hidden="true">/</span>}
{item.href ? (
<Link href={item.href} className="hover:text-gray-900">
{item.label}
</Link>
) : (
<span className="font-medium text-gray-900">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}El último elemento no tiene href, renderizándolo como texto plano para indicar la página actual. El separador usa aria-hidden para que los lectores de pantalla lo salten y se basen en la semántica <ol> en su lugar.
Variaciones
Con Separadores de Chevron
"use client";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && (
<svg
className="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
{item.href ? (
<Link href={item.href} className="hover:text-gray-900">
{item.label}
</Link>
) : (
<span className="font-medium text-gray-900">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}Reemplaza el separador de barra con un chevron SVG en línea. El chevron es aria-hidden ya que la lista ordenada ya transmite la jerarquía a las tecnologías asistivas.
Con Iconos
"use client";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && (
<svg
className="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
{item.href ? (
<Link href={item.href} className="flex items-center gap-1.5 hover:text-gray-900">
{item.icon && <span className="h-4 w-4">{item.icon}</span>}
{item.label}
</Link>
) : (
<span className="flex items-center gap-1.5 font-medium text-gray-900">
{item.icon && <span className="h-4 w-4">{item.icon}</span>}
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>
);
}Cada elemento de miga de pan puede mostrar opcionalmente un icono a la izquierda de su etiqueta. Esto es especialmente útil para el elemento "Inicio", que a menudo se representa con un icono de casa en lugar de texto.
Truncado con Puntos Suspensivos
"use client";
import { useState } from "react";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
maxVisible?: number;
}
export function Breadcrumb({ items, maxVisible = 3 }: BreadcrumbProps) {
const [expanded, setExpanded] = useState(false);
const shouldTruncate = items.length > maxVisible && !expanded;
const visibleItems = shouldTruncate
? [items[0], ...items.slice(-(maxVisible - 1))]
: items;
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{visibleItems.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
{/* Botón de puntos suspensivos insertado después del primer elemento */}
{shouldTruncate && index === 1 && (
<>
<button
onClick={() => setExpanded(true)}
className="rounded px-1 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Mostrar la ruta completa de migas de pan"
>
...
</button>
<span className="text-gray-400" aria-hidden="true">/</span>
</>
)}
{item.href ? (
<Link href={item.href} className="hover:text-gray-900">
{item.label}
</Link>
) : (
<span className="font-medium text-gray-900">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}Cuando el camino de migas de pan excede maxVisible elementos, los elementos intermedios se colapsan en un botón de puntos suspensivos que se puede hacer clic. Al hacer clic, se revela la ruta completa. El primer y último elemento siempre permanecen visibles para el contexto.
Con Menú Desplegable para Elementos Ocultos
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
maxVisible?: number;
}
export function Breadcrumb({ items, maxVisible = 3 }: BreadcrumbProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const shouldTruncate = items.length > maxVisible;
const hiddenItems = shouldTruncate ? items.slice(1, -(maxVisible - 1)) : [];
const visibleItems = shouldTruncate
? [items[0], ...items.slice(-(maxVisible - 1))]
: items;
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [dropdownOpen]);
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{visibleItems.map((item, index) => (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
{shouldTruncate && index === 1 && (
<>
<div ref={dropdownRef} className="relative">
<button
onClick={() => setDropdownOpen((prev) => !prev)}
className="rounded px-1.5 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Mostrar elementos ocultos de migas de pan"
aria-expanded={dropdownOpen}
>
...
</button>
{dropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 min-w-[160px] rounded-lg border bg-white py-1 shadow-lg">
{hiddenItems.map((hidden, hIdx) => (
<Link
key={hIdx}
href={hidden.href ?? "#"}
className="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setDropdownOpen(false)}
>
{hidden.label}
</Link>
))}
</div>
)}
</div>
<span className="text-gray-400" aria-hidden="true">/</span>
</>
)}
{item.href ? (
<Link href={item.href} className="hover:text-gray-900">
{item.label}
</Link>
) : (
<span className="font-medium text-gray-900">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}En lugar de expandirse en línea, los elementos ocultos aparecen en un menú desplegable. El menú desplegable se cierra al hacer clic fuera. Esto es preferible cuando la ruta completa de migas de pan es muy larga y rompería el diseño.
Dinámica desde Ruta
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface BreadcrumbOverride {
[segment: string]: string;
}
interface DynamicBreadcrumbProps {
overrides?: BreadcrumbOverride;
homeLabel?: string;
}
export function DynamicBreadcrumb({ overrides = {}, homeLabel = "Home" }: DynamicBreadcrumbProps) {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const items = segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
const label = overrides[segment] ?? segment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return { label, href };
});
const all = [{ label: homeLabel, href: "/" }, ...items];
return (
<nav aria-label="Breadcrumb">
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{all.map((item, index) => (
<li key={item.href} className="flex items-center gap-1.5">
{index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
{index === all.length - 1 ? (
<span className="font-medium text-gray-900">{item.label}</span>
) : (
<Link href={item.href} className="hover:text-gray-900">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
}
// Uso
// <DynamicBreadcrumb overrides={{ "user-settings": "Configuración", "123": "John Doe" }} />Construye automáticamente el camino de migas de pan desde la ruta de URL actual. Los segmentos se escriben con mayúsculas de título de forma predeterminada, pero el mapa overrides te permite proporcionar etiquetas legibles para slugs e IDs dinámicos.
Implementación Compleja
"use client";
import { createContext, useContext, useMemo, useState, useRef, useEffect, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
// --- Tipos ---
interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
}
interface BreadcrumbContextValue {
items: BreadcrumbItem[];
setItems: (items: BreadcrumbItem[]) => void;
}
// --- Contexto ---
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
export function BreadcrumbProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<BreadcrumbItem[]>([]);
return (
<BreadcrumbContext.Provider value={{ items, setItems }}>
{children}
</BreadcrumbContext.Provider>
);
}
export function useBreadcrumb() {
const ctx = useContext(BreadcrumbContext);
if (!ctx) throw new Error("useBreadcrumb debe usarse dentro de BreadcrumbProvider");
return ctx;
}
// --- Hook para establecer migas de pan desde una página ---
export function useSetBreadcrumbs(items: BreadcrumbItem[]) {
const { setItems } = useBreadcrumb();
const serialized = JSON.stringify(items);
useEffect(() => {
setItems(JSON.parse(serialized));
}, [serialized, setItems]);
}
// --- Separador ---
function Separator() {
return (
<svg
className="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
// --- Menú desplegable para elementos ocultos ---
interface EllipsisDropdownProps {
items: BreadcrumbItem[];
}
function EllipsisDropdown({ items }: EllipsisDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [open]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
},
[]
);
return (
<div ref={ref} className="relative" onKeyDown={handleKeyDown}>
<button
onClick={() => setOpen((prev) => !prev)}
className="rounded px-1.5 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Mostrar elementos ocultos de migas de pan"
aria-expanded={open}
aria-haspopup="true"
>
...
</button>
{open && (
<div
role="menu"
className="absolute left-0 top-full z-20 mt-1 min-w-[180px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
{items.map((item, idx) => (
<Link
key={idx}
href={item.href ?? "#"}
role="menuitem"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setOpen(false)}
>
{item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
{item.label}
</Link>
))}
</div>
)}
</div>
);
}
// --- Componente Breadcrumb ---
interface BreadcrumbProps {
items?: BreadcrumbItem[];
maxVisible?: number;
separator?: React.ReactNode;
className?: string;
}
export function Breadcrumb({ items: propItems, maxVisible = 4, separator, className }: BreadcrumbProps) {
const ctx = useContext(BreadcrumbContext);
const items = propItems ?? ctx?.items ?? [];
const { visible, hidden, shouldTruncate } = useMemo(() => {
if (items.length <= maxVisible) {
return { visible: items, hidden: [], shouldTruncate: false };
}
return {
visible: [items[0], ...items.slice(-(maxVisible - 1))],
hidden: items.slice(1, -(maxVisible - 1)),
shouldTruncate: true,
};
}, [items, maxVisible]);
if (items.length === 0) return null;
const sep = separator ?? <Separator />;
return (
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex items-center gap-1.5 text-sm text-gray-500">
{visible.map((item, index) => {
const isLast = index === visible.length - 1;
return (
<li key={index} className="flex items-center gap-1.5">
{index > 0 && sep}
{shouldTruncate && index === 1 && (
<>
<EllipsisDropdown items={hidden} />
{sep}
</>
)}
{isLast || !item.href ? (
<span
className={`flex items-center gap-1.5 ${
isLast ? "font-medium text-gray-900" : ""
}`}
aria-current={isLast ? "page" : undefined}
>
{item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
<span className="max-w-[200px] truncate">{item.label}</span>
</span>
) : (
<Link
href={item.href}
className="flex items-center gap-1.5 hover:text-gray-900"
>
{item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
<span className="max-w-[200px] truncate">{item.label}</span>
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}Aspectos clave:
- Controlado por contexto o props — el componente lee elementos desde props directos o contexto de
BreadcrumbProvider, permitiendo que las páginas establezcan su camino de migas de pan de forma declarativa a través deuseSetBreadcrumbs. - Truncación automática con menú desplegable — cuando los elementos exceden
maxVisible, los elementos intermedios se colapsan en un botón de puntos suspensivos que revela un menú desplegable con roles ARIA correctos (menu,menuitem). - Truncación de etiqueta — las etiquetas individuales usan
max-w-[200px] truncatepara evitar que títulos de página extremadamente largos rompan el diseño. aria-current="page"— el último elemento está marcado conaria-current="page"para que los lectores de pantalla lo anuncien como la ubicación actual, siguiendo las prácticas de autoría de migas de pan de WAI-ARIA.- Soporte de separador personalizado — el prop
separatoracepta cualquier ReactNode, con valor predeterminado de un SVG de chevron. Esto permite una fácil personalización a barras, flechas u otros divisores. - Menú desplegable accesible por teclado — el menú desplegable de puntos suspensivos se cierra en Escape y al hacer clic fuera, con atributos
aria-expandedyaria-haspopupapropiados. - Dependencia de efecto serializada —
useSetBreadcrumbsserializa el array de elementos a JSON para la dependencia del efecto, evitando bucles de re-renderizado infinitos a partir de nuevas referencias de array.
Errores Comunes
-
Usar
<ul>en lugar de<ol>— Las migas de pan representan una secuencia ordenada. Los lectores de pantalla anuncian la posición (por ejemplo, "elemento 2 de 5") con<ol>, lo que se pierde con<ul>. -
Los separadores decorativos se leen en lectores de pantalla — Si los separadores están dentro de los elementos de lista y no están marcados con
aria-hidden="true", los lectores de pantalla los anunciarán (por ejemplo, "barra" o "mayor que"). -
Hacer que la página actual sea un enlace — El último elemento de miga de pan no debe ser un enlace ya que representa la página actual. Vincularlo crea una navegación confusa de auto-referencia.
-
No usar
aria-label="Breadcrumb"en la navegación — Sin una etiqueta, los lectores de pantalla no pueden distinguir la navegación de migas de pan de otros elementos<nav>en la página. -
Los breadcrumbs dinámicos causan cambios de diseño — Si los elementos de migas de pan se cargan de forma asincrónica, el componente puede saltar en altura. Reserva espacio o usa un esqueleto para evitar cambios de diseño acumulativos.
-
La truncación oculta contexto importante — Colapsar demasiado agresivamente (por ejemplo,
maxVisible={2}) puede ocultar la parte más significativa de la ruta. Elige un umbral que mantenga suficiente contexto visible. -
Olvidar
aria-current="page"en el último elemento — Este atributo le dice a las tecnologías asistivas qué elemento representa la página actual. Sin él, las migas de pan son menos útiles para los usuarios de lectores de pantalla.
Relacionado
- Barra lateral — Navegación vertical que complementa las migas de pan
- Paginación — Otro componente de navegación para contenido secuencial
- Botón — Elementos interactivos usados en menús desplegables de migas de pan