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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/breadcrumb-dropdown.jsonPrompt
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
maxItemscount: 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-disabledrow 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>.
Back Button
A back control that traverses history when there is an in-app page behind it and follows a real href when the visitor arrived cold — so a deep link never dead-ends.
Page Transition
A route swap wrapper: it holds the outgoing view until the incoming one is ready, animates the change, puts each route's scroll offset back and moves focus into the new view.