How to build a breadcrumb with per-segment dropdown menus in React
A React breadcrumb with dropdown menus renders each path segment as a button; segments that have children open a floating panel animated with Framer Motion AnimatePresence, while click-outside detection via a ref closes it automatically. Each trigger carries aria-expanded and aria-haspopup for full keyboard and screen-reader support.
- Stack: React + Framer Motion 11 + Lucide React, ~225 lines, zero extra dependencies.
- Core APIs: AnimatePresence, motion.div with scale + opacity + y, useRef click-outside pattern.
- Accessible: aria-expanded, aria-haspopup on each trigger button, nav aria-label on the wrapper.
- Segments stagger-animate on mount (0.08s delay per index) so the breadcrumb appears to build left to right.
- Works on mobile (layout wraps), but dropdowns require a tap, hover states are desktop-only.
Breadcrumb Dropdown Nav elevates a standard path indicator into an in-page navigation tool. Each segment can expose a floating list of sibling or child pages, so users jump sideways in the hierarchy without going back to a listing page. The whole bar entrance-animates on mount, and every dropdown panel springs open and closes with spring-physics easing.
Anatomy
The root is a semantic nav element with aria-label. Inside, a flex row renders one DropdownSegment per path entry. Each DropdownSegment holds three parts: a slash separator (skipped for the first segment), a button trigger showing an optional icon, the segment label, and a rotating ChevronDown, then an AnimatePresence-gated floating div for the dropdown panel listing child links as anchor tags.
How it works
Each segment manages its own open/closed state with useState. A useEffect attaches a mousedown listener to document and compares the click target against the segment ref, if outside, the panel closes. The dropdown panel animates with initial `{ opacity: 0, y: -4, scale: 0.96 }` and animate `{ opacity: 1, y: 0, scale: 1 }` via Framer Motion, using the spring-like ease `[0.16, 1, 0.3, 1]`. The ChevronDown icon itself is a motion.span with `animate={{ rotate: open ? 180 : 0 }}`, providing clear visual feedback of the panel state.
How to build it in React
Define the data types and icon map
Start with two interfaces: BreadcrumbChild (label + href) and BreadcrumbSegment (label, optional href, optional icon key, optional children array). Build an iconMap object that maps the string keys 'home', 'folder', 'file' to their Lucide components, so segments declare an icon by name rather than by importing it directly.
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, };Build the DropdownSegment with click-outside detection
Give each segment its own useState(false) for open/closed. Attach a ref to the wrapping motion.div, then in a useEffect add a mousedown listener on document. Inside the handler, check if the event target is outside the ref, if so, call setOpen(false). Return a cleanup that removes the listener. This pattern isolates each segment so multiple dropdowns never conflict.
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); }, []);Animate the dropdown panel with AnimatePresence
Wrap the conditional panel in AnimatePresence so Framer Motion can play the exit animation before unmounting. Give the motion.div initial and exit states of opacity 0, y -4, and scale 0.96, with animate restoring them to 1/0/1. Use the custom easing array [0.16, 1, 0.3, 1] for an overshoot-free spring feel without a real spring config.
<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>Stagger the breadcrumb entrance on mount
Pass the segment index down to DropdownSegment and use it in the motion.div transition delay: index * 0.08 seconds. Combined with initial opacity 0 and y -8, this makes the breadcrumb appear to assemble left to right on first render, giving the nav a polished feel without a separate orchestrator component.
<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] }} >
When to use it
Reach for this pattern on documentation sites, e-commerce category trees, SaaS dashboards with deep hierarchies, or any admin panel where users frequently need to navigate laterally within a section. Skip it for flat sites with two or three levels, a plain breadcrumb is cleaner. On mobile the dropdowns still work via tap, but make sure tap targets are at least 44px tall for comfortable use.
Used by
- Notion, Uses an inline breadcrumb with clickable parent-page dropdowns for navigating workspace hierarchies.
- Vercel, Dashboard breadcrumbs expose project and team selectors as dropdown menus for fast context switching.
- GitHub, Repository file browser uses a path breadcrumb where each segment links or branches to sibling directories.
- Linear, Issue detail breadcrumbs offer inline team and project pickers as compact dropdown overlays.
FAQ
How does click-outside closing work without a global state library?
Each DropdownSegment attaches its own mousedown listener to document inside a useEffect. It compares the click target with the segment ref using Node.contains. If the target is outside, setOpen(false) fires. The cleanup removes the listener on unmount, so there are no memory leaks even when segments are conditionally rendered.
Can two dropdowns be open at the same time?
Yes, in the current implementation each segment is fully independent. To enforce single-open behavior, lift the open state to the parent and pass an onOpen callback that closes all other segments when one is triggered.
Is the breadcrumb accessible for keyboard navigation?
The trigger uses a native button element which is focusable by default and receives aria-expanded and aria-haspopup attributes. The nav wrapper carries aria-label. For full keyboard support add keydown handlers for Escape (close panel) and Arrow keys (cycle through dropdown items).
How do I replace the mock data with real router links?
Replace the anchor tags inside the dropdown panel with your router's Link component (Next.js Link, React Router Link, etc.) and pass the segments array from your routing context or a static site map. The component is purely presentational, so swapping the link element requires no changes to the animation logic.