Toolbar
A grouped application toolbar that measures itself and folds its lowest-priority actions into a "More" menu — no breakpoints, roving tabindex, portalled overflow menu.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils, clip-bounds
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toolbar.jsonPrompt
Build a React + TypeScript + Tailwind "Toolbar" component using lucide-react
for the overflow glyphs and react-dom's createPortal for the floating layers.
Contract
- Shared primitive: clip-bounds (installed automatically via
registryDependencies) — `clipBounds(anchor)` is the overflow menu's boundary.
If rebuilding without it: the viewport rect intersected with the rect of
every ancestor below <body> whose computed overflow-x or overflow-y is auto or
scroll.
- ToolbarItem is a union discriminated on `type`:
- button (default, `type` omitted): { id, label, icon?, shortcut?,
disabled?, tooltip?, priority?, iconOnly?, onSelect?() }
- toggle ({ ...same, type: "toggle", pressed? }) — controlled: the
component never flips `pressed`, it only reports the intent.
- separator ({ id, type: "separator" }) — a `role="separator"` rule.
- custom ({ id, type: "custom", label, render(ctx: { size }) }) — an
arbitrary slot in a `role="group"` wrapper named by `label`.
- ToolbarProps extends React.HTMLAttributes<HTMLDivElement>: { items,
align = "start"|"center"|"end" (default "start"), size = "sm"|"md"|"lg"
(default "md"), variant = "default"|"ghost"|"floating" (default
"default"), label = "Toolbar", iconOnly = false, moreLabel = "More
actions", onOverflowChange?(ids: string[]), className }.
- forwardRef to the root div; rest props spread onto it.
- `label` is the accessible name of the toolbar; every item's `label` is the
accessible name of its control (it becomes `aria-label` when the item is
painted icon-only), so an icon-only bar is never nameless.
Behavior
- Overflow collapse is measured, never guessed. A ResizeObserver on the root
triggers a pass; the pass reads the root's own content width (clientWidth
minus computed padding), the computed column-gap, and every mounted item's
ceil'd getBoundingClientRect().width into a cache keyed by item id. Do NOT
use breakpoints or container queries: the same bar has to be correct in a
resizable split pane, a 320px sidebar and a full-width editor.
- Each pass restarts from "everything visible" and removes candidates until
the row fits, so the answer is a pure function of (widths, gap, available
width). Incrementally adding/removing around the previous answer makes the
bar oscillate as the "More" trigger's own width enters and leaves the
budget. Removal order: lowest `priority` first, ties broken right-most
first; non-finite priorities are clamped to 0. Separator and custom items
are never candidates — a `custom` slot has no valid representation as a
menu row, so it stays pinned inline (say so in the docs).
- Measurement → setState always goes through requestAnimationFrame. Writing
state straight from the ResizeObserver callback produces "ResizeObserver
loop completed with undelivered notifications" once the resulting layout
feeds back into the observed box. Also re-schedule a pass whenever the
split itself changed (collapsing does not change the bar's width, so the
observer would never fire again — but that next pass is the one that finally
sees a re-expanded item's real width), and once on document.fonts.ready,
because web fonts land after the first measurement and move every label.
- NO MARGINS on any item box. getBoundingClientRect() reports the border box,
so a margin would be spent in layout but missing from the budget and the bar
would over-commit by exactly the margin total. Space items with the flex gap,
which IS read back from the computed style.
- The root is `w-full min-w-0 overflow-hidden`: its width is the input to the
maths, so it must come from the parent and never from the content (a pill
layout needs a constrained wrapper, not a prop). overflow-hidden keeps the
single pre-measurement frame — and any item too wide to ever fit — clipped
instead of blowing out the page.
- Separators earn their pixels only when they separate two visible items:
leading, trailing and doubled-up rules are dropped from both the layout and
the width budget. When items on both sides of a rule collapse, a matching
separator is re-inserted in the menu so the grouping survives the move.
- Keyboard: the whole bar is ONE tab stop (roving tabindex). ←/→ move between
controls, disabled ones included (wrapping), Home/End jump to the ends, and the
active id is derived on every render rather than stored, so an item that just
collapsed into the menu hands the tab stop to a live sibling in the same render
instead of one effect later. Keys typed inside a `custom` slot are left alone.
Focus uses { preventScroll: true } because the bar clips its own overflow.
- Disabled controls carry `aria-disabled` and a no-op handler, NEVER the
`disabled` attribute — in the bar and in the menu alike. The browser blurs a
node the instant it is disabled, so a button that disables itself (Undo
emptying the history) drops focus on <body> mid-press: measured on the shipped
build, one Enter on Undo left `document.activeElement === document.body`, the
arrows dead and the tab stop silently back on the first item. `aria-disabled`
also keeps the control focusable, tooltip-able and announced, which is how the
keyboard user hears *why* it is unavailable ("Comment (read-only file)").
Consequence for the menu: its item query must NOT filter `:not(:disabled)`, or
the reason text is unreachable again.
- The overflow menu is portalled to document.body with position: fixed, so no
`overflow: hidden` ancestor (a card, a docs preview stage) can clip it. It is
flipped/clamped against the viewport intersected with the nearest scrollable
ancestors — `overflow: hidden` ancestors are NOT boundaries, the portal
already escaped them and clamping to one would crush the menu. Measure the
panel's natural size with its own inline caps momentarily lifted (a panel
already capped by the previous pass always looks like it fits, which is the
classic flip jitter) and restore its scrollTop afterwards.
- Then SHIFT the layer back inside that boundary on BOTH axes, not just the
cross axis. It is `position: fixed`, so anything hanging past the boundary can
never be scrolled to. ~96px is the *preferred* height on a short side — the
menu keeps it and scrolls — but the cap yields to a boundary too small to hold
that much, because a squeezed menu is usable and an escaped one is not.
Measured on the shipped build with a cap but no main-axis shift: a "More"
trigger parked at the top of a 60px scrollable strip left 74 of the menu's
95px off-screen, `elementFromPoint` on its centre returned the page behind it
and only 1 of 3 rows could be clicked; the same trigger inside a 40px
horizontally scrollable bottom bar put the menu 97px above the strip, on top
of unrelated content. With the shift both cases sit fully inside (a 44px and a
24px scrolling menu), 0px outside the boundary.
- Until the first measurement lands the menu has no coordinates and is
`opacity-0 pointer-events-none` — NEVER `visibility: hidden`, which makes the
subtree unfocusable so focus() fails silently and Escape/arrow keys die
with it.
- Menu keyboard: ↑↓ cycle, Home/End jump, Escape closes and returns focus to
the trigger (restore in a rAF and only if the node is still `isConnected` —
the action often re-renders or removes the trigger, and focusing a detached
node silently drops focus on <body>). Escape also stopPropagation()s so a
toolbar nested in another overlay only closes the innermost layer. Tab moves
focus back to the trigger synchronously and lets the browser continue from
there. Outside pointerdown dismisses without stealing focus back.
- Menu rows keep the item's real state: a disabled item stays disabled (and
stays focusable — see above), a toggle
renders as role="menuitemcheckbox" with aria-checked and a check glyph, and
the shortcut string is shown right-aligned. Activating a toggle keeps the
menu open (so several can be flipped and their state seen); activating a
one-shot button closes it, like any menu command.
- Tooltips: one shared portalled `role="tooltip"` layer. Hover opens it after
~320ms, keyboard focus opens it immediately (only for :focus-visible, so a
mouse click does not double up), and it closes on leave/blur/activation. The
described button gets aria-describedby while it is open. Icon-only items fall
back to their `label`; `tooltip` overrides. `shortcut` is a display string
supplied by the consumer — never sniff the platform from navigator.
Rendering & styling
- Semantic tokens only: bg-card + border for the "default" chrome, nothing for
"ghost", rounded-full + shadow-lg for "floating"; bg-popover /
text-popover-foreground for the menu; bg-foreground / text-background for the
tooltip; bg-border for separators; hover:bg-accent hover:text-accent-foreground
for idle controls; bg-primary text-primary-foreground for a pressed toggle
(on a monochrome palette an accent tint is indistinguishable from hover, so
the pressed state inverts instead); focus-visible:ring-2 ring-ring
everywhere; opacity-50 + cursor-not-allowed and no hover styling for an
aria-disabled control (a `disabled:` variant would never match — there is no
`disabled` attribute — and pointer-events must stay on so it can be focused
and can still show its tooltip). cn() merges the consumer className onto the
root. A pressed toggle must not change font weight or padding — a width
change on state would restart the overflow maths on every click.
- Entrance keyframes ship in a React 19 hoisted <style href> (deduped across
instances) and are disabled under motion-reduce; nothing about the collapse,
the menu or the keyboard depends on animation.
Customization levers
- priority: the whole collapse policy. Give the two or three controls a user
must never lose (Save, Bold) the highest numbers and destructive/rare ones
the lowest; ties fold right-to-left, so plain descending numbers reproduce
"the right-hand end goes first".
- iconOnly (per item, or as a bar-level default): the single biggest lever on
how much fits before anything collapses; pair it with `tooltip`/`shortcut`
so the names are still discoverable.
- size sm|md|lg and variant default|ghost|floating change density and chrome
only — the measurement, the menu and the keyboard model are identical.
- align start|center|end positions the row inside the always-full-width bar;
constrain the parent to get a floating pill.
- moreLabel / label: rename the trigger and the toolbar for a localized or
more specific accessible name.
- onOverflowChange: wire it to persist which controls a user actually sees, to
drive a "customize toolbar" affordance, or (as in the demo) to display the
live split.
- custom items: drop in a zoom readout, a page counter or a colour swatch;
keep them narrow, they never collapse.Concepts
- Priority-based collapse — the bar never truncates or wraps: the lowest-
priorityaction leaves the row first and lands in the "More" menu intact, still disabled or still pressed. Ties fold right-to-left, so plain descending numbers give you "the far end goes first". - Measured, not breakpointed — the split comes from the bar's real content width and each item's real natural width, so one instance is simultaneously correct in a 320px sidebar, a drag-resized split pane and a 1400px editor without a single media query.
- Restart-per-pass — every pass recomputes from "everything visible" instead of nudging the previous answer, which makes the result a pure function of the measurements; nudging makes the bar flicker between two states as the "More" trigger's own width enters and leaves the budget.
- Roving tabindex — the whole strip is one tab stop; ←/→ walk the controls (disabled ones included, so nobody has to guess why a command is missing), Home/End jump to the ends, and the active stop is derived each render so a control that just collapsed into the menu hands it straight to a live sibling.
aria-disabled, neverdisabled— the attribute blurs its own element, so a button that disables itself on press throws the keyboard user back to<body>and quietly moves the tab stop. The ARIA state keeps the control focusable, announced and tooltip-able; the handler is what refuses to run.- Portal-first overflow menu — the menu renders into
document.bodyatposition: fixed, so a rounded card, a masked hero or this page's own preview stage cannot clip it; it is then flipped, size-capped and shifted back inside the viewport and any scrollable ancestor on both axes. The main axis needs that shift as much as the cross axis: a fixed layer hanging past its boundary can never be scrolled to — measured before the clamp existed, a trigger at the top of a 60px scrollable strip left 74 of the menu's 95px off-screen with 1 of 3 rows clickable. The ~96px floor is a preference, not a guarantee: it yields to a boundary too small to hold it, because a squeezed menu is usable and an escaped one is not. - Pinned custom slots — a
customitem (a zoom readout, a page counter) has no honest representation as a menu row, so it is excluded from collapsing and always stays inline.
Tree Map
A squarified treemap that tiles a hierarchy by value — nested groups, click-to-drill with a breadcrumb, rank-based shading and keyboard-reachable DOM tiles.
Version History
A day-grouped snapshot list for documents and configs — pick one to preview, tick two to compare, and roll one back behind a real confirmation with pending, failure and retry.