Navigation

Breadcrumb Dropdown

A breadcrumb trail that measures itself and folds the middle segments it cannot fit into a dropdown, keeping the root and the current page painted at every width.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

/**
 * Joins the labels into one comparable dependency key. A control character rather

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/breadcrumb-dropdown.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BreadcrumbDropdown" component using
lucide-react for icons and the shadcn dropdown-menu primitive for the popup.

Contract
- export const BreadcrumbDropdown = React.forwardRef<HTMLElement, BreadcrumbDropdownProps>
  rendering a <nav>; spread the remaining native props on the root and merge
  className with cn().
- items: { label: string; href?: string; icon?: React.ReactNode; reason?: string }[],
  root first and current page last. The last entry is the current page: it omits
  href and renders as inert text with aria-current="page". An entry without href
  anywhere else is a grouping level that has no page of its own — inert text in
  the trail, a refusing row in the menu, with reason explaining why. reason is
  menu-only, so it can never widen the trail.
- label?: string = "Breadcrumb" — accessible name of the <nav>.
- menuLabel?: string = "Hidden path segments" — accessible name of the trigger,
  with the live count appended: "Hidden path segments (4)".
- separator?: React.ReactNode = a lucide ChevronRight, rendered between cells and
  hidden from assistive tech.
- onNavigate?: (item, index, event) => void — fires from the trail and from the
  menu alike, before the browser follows the link. Call preventDefault() and use
  your router for client-side navigation; leave it off and the plain <a href>
  wins. Routes belong to the consumer; the component never invents one.
- onOverflowChange?: (hiddenCount: number) => void — fires on every re-split. The
  collapsed run is always contiguous and always starts at index 1, so the hidden
  items are exactly items.slice(1, 1 + hiddenCount).

Behavior
- Measurement, not counting. A hidden twin layer carries one span per crumb plus
  one separator and one trigger, each with the SAME class string as its painted
  counterpart, laid out w-max so every twin reports its natural width. The layer
  is visibility:hidden and NEVER display:none — a display:none box has no
  geometry to measure, which is the trap this pattern exists to avoid — and it
  sits inside a 0x0 overflow-hidden shell, because a hidden box still contributes
  scrollable overflow and would otherwise scroll the whole page sideways.
- The split. Let widths[] be the twin widths, sep one separator cell, more the
  trigger, gap the row's computed column-gap, available the row's content width.
  The root (index 0) and the current page (last index) never collapse, so the one
  free variable is tail = how many trailing crumbs survive beside the root. Walk
  tail from count-1 down to 1 and take the FIRST candidate that fits:
    hidden = count - 1 - tail
    cells  = 1 + tail + (hidden > 0 ? 1 : 0)
    cost   = widths[0] + sum(widths[count-tail .. count-1])
           + (hidden > 0 ? more : 0)
           + (cells - 1) * sep + gap * 2 * (cells - 1)
    fits   = cost <= available + 0.5
  The gap term is 2*(cells-1) because a row of E element cells holds 2E-1 flex
  children once the separators are counted; the +0.5 absorbs sub-pixel rounding
  between ceil'd cell widths and a fractional container. tail = count-1 is the
  only candidate costed WITHOUT the trigger, so a trail that fits renders no
  trigger at all and one narrow crumb is never hidden to pay for a wider trigger.
  Each pass restarts from "everything visible" rather than nudging the previous
  answer, which makes the result a pure function of (widths, sep, more, gap,
  available) — that is what stops the trail oscillating as the trigger's own
  width enters and leaves the budget.
- Edge cases. count <= 2 has no middle, so nothing ever collapses. Narrower than
  root + trigger + current page: keep exactly those three and let the current
  page truncate — it is the only cell allowed to shrink, losing "you are here" is
  worse than losing characters, and dropping the trigger would lose the rest of
  the path. count === 1 renders the current page alone: no separator, no trigger,
  nothing focusable. count === 0 renders an empty list.
- Re-measure on a ResizeObserver over the row AND every twin (a late web font, a
  zoom change or an edited label moves a twin without touching the row), on window
  resize, and on document.fonts.ready. Every callback hops through one
  requestAnimationFrame: writing state straight from a ResizeObserver callback is
  what produces "ResizeObserver loop completed with undelivered notifications".
  Measure in useLayoutEffect (useEffect on the server) so the FIRST painted frame
  is already collapsed instead of one expanded, clipped frame; the
  pre-measurement render carries the whole trail, so server HTML is complete with
  no JavaScript.
- ARIA. <nav aria-label> wrapping a single <ol>; every crumb and every separator
  is its own <li>; separator items are aria-hidden + role="presentation" so a
  screen reader hears only the labels. The current page is inert text with
  aria-current="page", never a link. The trigger is a real button whose
  accessible name carries the count, and the primitive supplies aria-haspopup,
  aria-expanded and aria-controls. Menu rows are emitted in trail order, so
  arrowing down walks the path from the root towards the current page.
- Keyboard. Tab / Shift+Tab move through the trail: crumbs are plain anchors, so
  no roving tabindex is needed or wanted. On the trigger, Enter / Space /
  ArrowDown open the menu and focus its first row. Inside the menu, ArrowDown /
  ArrowUp / Home / End move, typeahead jumps to a label, Enter activates the
  focused row and closes the menu, Escape closes and returns focus to the
  trigger. Tab is swallowed by the primitive while the menu is open, so Escape
  is the way back out to the trail.
- Refusal. A row whose item has no href gets aria-disabled — never the
  primitive's disabled prop, which drops it out of the menu's own keyboard walk
  and takes its reason with it. Its onSelect calls preventDefault() so the menu
  stays open: closing would look like the activation worked.
- Closing after a client-side activation is the component's job, not the
  primitive's. A consumer routing client-side calls preventDefault() on the
  click, and the primitive skips its own select — and the close that rides along
  with it — on any click that was default-prevented, so the menu would sit open
  over the page it just navigated to. Close it from the row's own onClick when
  event.defaultPrevented, and only then: an ordinary click still closes through
  the primitive, and unmounting the anchor yourself would race the browser's own
  navigation.
- Focus is never dropped on <body>. Two symmetric hazards: a focused crumb
  collapsing into the menu (the container narrowed) and a focused trigger
  unmounting because the last hidden segment came back (the container widened).
  Track "focus was inside" with onFocusCapture / onBlurCapture, treating a blur
  into the portalled menu as still inside; then after any re-split, if
  document.activeElement is <body>, hand focus to a deliberate successor — the
  trigger when it exists (it now holds the segment the user was on), otherwise
  the first crumb after the root, which is exactly where the trigger stood. Do it
  inside one rAF so the menu's own focus restoration gets to go first.
- Menu state. If the menu is open when the last hidden segment returns, close it
  during render rather than in an effect, or it springs open by itself the next
  time the container narrows.
- Cleanup. Disconnect the observer, drop the resize listener and cancel every
  pending rAF on unmount and whenever the item list changes.

Rendering & styling
- Semantic tokens only: text-muted-foreground for links and the trigger,
  text-foreground + font-medium for the current page, hover:bg-accent /
  hover:text-accent-foreground, focus-visible:ring-2 ring-ring ring-inset (inset,
  because the row clips its own overflow and an outset ring would be sliced),
  text-muted-foreground/60 for separators, bg-popover / text-popover-foreground
  for the menu. No hex, rgb or oklch anywhere.
- The crumb box class is shared verbatim by the painted crumb and its twin, and
  nothing in it may change with hover or focus — a crumb that grew when hovered
  would restart the maths under the pointer.
- The row is flex, gap-1.5, min-w-0 and overflow-hidden: its width is the INPUT
  to the maths, so it must come from the parent and never from the content.
- The trigger's visible glyph never reflects the count (the count rides in the
  accessible name), because a trigger that grew from "2" to "9 more" would change
  the very budget that produced the count.
- The only motion is the menu's own open/close; disable it under motion-reduce.
  Nothing about the split depends on animation.

Customization levers
- Separator: pass a slash, a dot or plain text — it is measured like everything
  else, so the split adapts on its own with no constant to retune.
- Density: change the crumb box padding or text size in the single shared class
  string; painted crumbs and twins follow together, so the maths stays honest.
- What is pinned: the split only assumes index 0 and the last index survive. Keep
  two leading crumbs (workspace + project) by summing widths[0..1] and starting
  tail's range one lower — one edit in the cost function, nothing else moves.
- Menu shape: align, sideOffset, min-w / max-w on the content; indent each row by
  its depth to draw the hierarchy — menu-only, so trail widths are untouched.
- Trigger glyph: swap MoreHorizontal for an ellipsis or a folder icon, but keep
  it width-stable and keep the count in the accessible name.
- Truncation policy: the current page is the only shrinkable cell. Give it a
  max-width if you would rather it truncate earlier than the container demands.
- Router integration: swap the plain <a href> for next/link's Link in both the
  trail and the menu branch; measurement, ARIA and the collapse logic are
  untouched.
- Structured data: labels stay plain strings, so the page can walk the same items
  array to emit BreadcrumbList JSON-LD for SEO — a page-level concern, not the
  component's.

Concepts

  • Measured collapse — the trail asks the pixels, not a maxItems count: twin copies of every segment report their natural width, so the same component behaves correctly in a 200px sidebar and a 900px header with no breakpoint written anywhere.
  • Off-screen twin measurement — the widths of all seven segments stay readable while the trail already paints only the three that fit, so there is never a frame where it must be fully expanded to be measured, and the answer can never disturb its own input.
  • Root and current-page pinning — the two ends of the path are structurally incollapsible, which makes "where am I" and "take me back to the top" reachable at every width; only the middle is negotiable.
  • Refusal over removal — a segment with no page of its own stays in the menu as an aria-disabled row that announces its reason and declines activation, instead of silently disappearing or pretending to be a link.
  • Deliberate focus successor — when the focused crumb collapses into the menu, or the focused trigger unmounts because the trail widened, focus is handed to the cell that took its place rather than falling to <body>.

On This Page