Como crear un breadcrumb con menus desplegables por segmento en React
Un breadcrumb React con menus desplegables muestra cada segmento de la ruta como un boton; los segmentos que tienen hijos abren un panel flotante animado con AnimatePresence de Framer Motion, mientras que la deteccion de clic exterior mediante un ref lo cierra automaticamente. Cada disparador lleva aria-expanded y aria-haspopup para una accesibilidad completa con teclado y lectores de pantalla.
- Stack: React + Framer Motion 11 + Lucide React, ~225 lineas, cero dependencias adicionales.
- APIs clave: AnimatePresence, motion.div con scale + opacity + y, patron useRef de clic exterior.
- Accesible: aria-expanded, aria-haspopup en cada boton disparador, nav aria-label en el contenedor.
- Los segmentos se animan de forma escalonada al montar (0.08s de retardo por indice) para que el breadcrumb parezca construirse de izquierda a derecha.
- Funciona en movil (el layout salta de linea), pero los desplegables requieren un toque, los estados hover son solo para escritorio.
Breadcrumb Dropdown Nav convierte un simple indicador de ruta en una herramienta de navegacion dentro de la pagina. Cada segmento puede exponer una lista flotante de paginas hermanas o hijas, de modo que los usuarios saltan lateralmente en la jerarquia sin volver a una pagina de listado. Toda la barra se anima al aparecer, y cada panel desplegable se abre y se cierra con una fisica de spring.
Anatomía
La raiz es un elemento nav semantico con aria-label. Dentro, una fila flex renderiza un DropdownSegment por cada entrada de la ruta. Cada DropdownSegment contiene tres partes: un separador slash (omitido para el primer segmento), un boton disparador que muestra un icono opcional, la etiqueta del segmento y un ChevronDown que gira, y luego un div flotante controlado por AnimatePresence que lista los enlaces hijos como etiquetas anchor.
Cómo funciona
Cada segmento gestiona su propio estado abierto/cerrado con useState. Un useEffect adjunta un listener mousedown al document y compara el objetivo del clic con el ref del segmento, si se hace clic fuera, el panel se cierra. El panel del desplegable se anima con initial `{ opacity: 0, y: -4, scale: 0.96 }` y animate `{ opacity: 1, y: 0, scale: 1 }` mediante Framer Motion, usando el easing tipo spring `[0.16, 1, 0.3, 1]`. El propio icono ChevronDown es un motion.span con `animate={{ rotate: open ? 180 : 0 }}`, lo que aporta una retroalimentacion visual clara del estado del panel.
Cómo crearlo en React
Definir los tipos de datos y el mapa de iconos
Empieza con dos interfaces: BreadcrumbChild (label + href) y BreadcrumbSegment (label, href opcional, clave de icono opcional, array children opcional). Construye un objeto iconMap que asocie las claves 'home', 'folder', 'file' a sus componentes Lucide, para que los segmentos declaren un icono por nombre en lugar de importarlo directamente.
interface BreadcrumbSegment { label: string; href?: string; icon?: "home" | "folder" | "file"; children?: { label: string; href: string }[]; } const iconMap: Record<string, React.ElementType> = { home: Home, folder: Folder, file: FileText, };Construir el DropdownSegment con deteccion de clic exterior
Da a cada segmento su propio useState(false) para abierto/cerrado. Adjunta un ref al motion.div que lo envuelve, y luego en un useEffect anade un listener mousedown sobre document. Dentro del handler, comprueba si el objetivo del evento esta fuera del ref, si es asi, llama a setOpen(false). Devuelve un cleanup que elimina el listener. Este patron aisla cada segmento para que varios desplegables nunca entren en conflicto.
const ref = useRef<HTMLDivElement>(null); useEffect(() => { function handler(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []);Animar el panel desplegable con AnimatePresence
Envuelve el panel condicional en AnimatePresence para que Framer Motion pueda reproducir la animacion de salida antes de desmontar. Da al motion.div estados initial y exit de opacity 0, y -4, y scale 0.96, con animate restaurandolos a 1/0/1. Usa el array de easing [0.16, 1, 0.3, 1] para una sensacion de spring sin rebote, sin una configuracion spring real.
<AnimatePresence> {open && ( <motion.div initial={{ opacity: 0, y: -4, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -4, scale: 0.96 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} style={{ position: "absolute", top: "calc(100% + 4px)", left: 0 }} > {children} </motion.div> )} </AnimatePresence>Escalonar la entrada del breadcrumb al montar
Pasa el indice del segmento a DropdownSegment y usalo en el delay de transicion del motion.div: index * 0.08 segundos. Combinado con initial opacity 0 e y -8, esto hace que el breadcrumb parezca ensamblarse de izquierda a derecha en el primer renderizado, sin necesidad de un componente orquestador aparte.
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.08, duration: 0.3, ease: [0.16, 1, 0.3, 1] }} >
Cuándo usarlo
Recurre a este patron en sitios de documentacion, arboles de categorias de e-commerce, paneles SaaS con jerarquias profundas, o cualquier panel de administracion donde los usuarios necesitan navegar lateralmente dentro de una seccion con frecuencia. Evitalo en sitios planos de dos o tres niveles, un breadcrumb sencillo queda mas limpio. En movil los desplegables siguen funcionando al toque, pero asegurate de que las zonas de toque midan al menos 44px de alto para un uso comodo.
Usado por
- Notion, Usa un breadcrumb inline con desplegables de paginas padre clicables para navegar por las jerarquias del espacio de trabajo.
- Vercel, Los breadcrumbs del panel exponen selectores de proyecto y de equipo como menus desplegables para un cambio de contexto rapido.
- GitHub, El navegador de archivos del repositorio usa un breadcrumb de ruta donde cada segmento enlaza o bifurca hacia los directorios hermanos.
- Linear, Los breadcrumbs del detalle de un ticket ofrecen selectores de equipo y de proyecto inline como overlays desplegables compactos.
FAQ
Como funciona el cierre al hacer clic fuera sin una libreria de estado global?
Cada DropdownSegment adjunta su propio listener mousedown al document dentro de un useEffect. Compara el objetivo del clic con el ref del segmento mediante Node.contains. Si el objetivo esta fuera, se dispara setOpen(false). El cleanup elimina el listener al desmontar, asi que no hay fugas de memoria ni siquiera cuando los segmentos se renderizan de forma condicional.
Pueden estar abiertos dos desplegables al mismo tiempo?
Si, en la implementacion actual cada segmento es totalmente independiente. Para forzar el comportamiento de uno solo abierto, eleva el estado abierto al padre y pasa un callback onOpen que cierre todos los demas segmentos cuando se activa uno.
Es el breadcrumb accesible para la navegacion con teclado?
El disparador usa un elemento button nativo, enfocable por defecto, que recibe los atributos aria-expanded y aria-haspopup. El contenedor nav lleva aria-label. Para un soporte completo de teclado, anade manejadores keydown para Escape (cerrar el panel) y las teclas de flecha (recorrer los elementos del desplegable).
Como reemplazo los datos mock por enlaces reales del router?
Reemplaza las etiquetas anchor dentro del panel del desplegable por el componente Link de tu router (Next.js Link, React Router Link, etc.) y pasa el array segments desde tu contexto de routing o un mapa de sitio estatico. El componente es puramente presentacional, asi que cambiar el elemento de enlace no requiere ningun cambio en la logica de animacion.