Toolbar Overflow
An action bar that measures itself off-screen and moves the actions it cannot fit into a trailing overflow menu, lowest priority first.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toolbar-overflow.jsonPrompt
Build a React + TypeScript + Tailwind "ToolbarOverflow" component: an action bar
that measures itself and moves the actions it cannot fit into a trailing
overflow menu. lucide-react for the ellipsis glyph, the shadcn dropdown-menu
primitive for the menu, and a roving-tabindex hook for the keyboard.
Contract
- ToolbarOverflowAction:
{ id, label, icon?: ReactNode, priority?: number, pressed?: boolean,
disabled?: boolean, reason?: string, once?: boolean, shortcut?: string,
iconOnly?: boolean, onSelect?(): void }
- `id` must be unique and stable: it keys the width cache and the one-shot
ledger. `label` is the visible text and, icon-only, the accessible name.
- `pressed` PRESENT means "this is a toggle" — aria-pressed in the bar,
menuitemcheckbox in the menu. It is controlled: report the intent through
onSelect, never flip it internally.
- `reason` is appended to the accessible name ("Comment (read-only file)"),
which is how a keyboard user hears WHY something is unavailable.
- `shortcut` is a display-only string; never sniff the platform, and never
bind a global key for it.
- ToolbarOverflowProps extends React.HTMLAttributes<HTMLDivElement>:
{ actions, label = "Toolbar", moreLabel = "More actions",
size = "sm" | "md" (default "md"), iconOnly = false,
onOverflowChange?(hiddenIds: string[]), className }.
forwardRef to the root; the remaining native props spread onto it.
- onOverflowChange fires with the ids currently living in the menu, in source
order, on every re-split (including the first) — wire it to a counter, to
analytics, or to a "customise toolbar" affordance.
Behavior — measurement runs off-screen
- Render a MEASURING LAYER holding one twin per action plus one for the "More"
trigger, each at its natural width and sharing the real control's EXACT class
string (compute the string once per action and hand it to both, so the two can
never drift). Widths come from the twins, never from the painted row.
- The layer is `visibility: hidden`, NEVER `display: none`: a display:none box
has no geometry, so it cannot be measured at all — that is the trap this
whole pattern exists to avoid. Nothing inside it is focusable or exposed
(plain spans under one aria-hidden root), so the usual "visibility:hidden
kills focus" hazard does not apply.
- Wrap the layer in a 0x0 `overflow: hidden` shell, absolutely positioned. A
hidden element still contributes SCROLLABLE overflow: without the shell,
twelve twins laid out 900px wide inside a 320px viewport would make the
whole page scroll sideways. The shell clips that and leaves every twin's
layout — and therefore its width — untouched.
- Because the twins are independent of the split, the output can never feed
back into the input: no oscillation, no ResizeObserver loop warning, and no
second pass needed after a collapse.
- Measure in a useLayoutEffect (useEffect on the server), not a passive effect.
The re-render it schedules is flushed before the browser paints, so the FIRST
PAINTED FRAME is already collapsed instead of one expanded, clipped frame.
- Read three things: the bar's own content width (clientWidth minus computed
horizontal padding, floored), the computed column-gap, and every twin's
getBoundingClientRect().width, ceil'd. Compare against the stored metrics and
bail when nothing moved — a fractional width that wobbles by a rounding error
would otherwise keep producing "new" metrics forever.
- Re-measure on: a ResizeObserver over the bar AND over every twin (a late web
font, a zoom change, an edited label or a density switch all move a twin
without touching the bar), plus window resize, plus document.fonts.ready.
Observer callbacks schedule through requestAnimationFrame — writing state
straight from a ResizeObserver callback is what produces "ResizeObserver loop
completed with undelivered notifications".
- NO MARGINS on any control. getBoundingClientRect reports the border box, so a
margin would be spent in layout but missing from the budget. Space with the
flex gap, which IS read back from the computed style.
Behavior — the split
- Cost of a candidate set: sum(widths) + gap*(kept-1), plus gap + triggerWidth
whenever anything was left over. A zero-kept row pays for no leading gap, so
its cost is exactly the trigger.
- Only the ALL-VISIBLE candidate is costed WITHOUT a trigger. Consequence: a bar
that fits exactly renders no trigger at all, and hiding one narrow action to
pay for a wider trigger is never the chosen answer.
- Every pass RESTARTS from "everything visible" and removes candidates until the
row fits, so the answer is a pure function of (widths, gap, available width).
Nudging the previous answer instead makes the bar flicker between two states
as the trigger's own width enters and leaves the budget.
- Removal order: lowest `priority` first (default 0, non-finite clamped to 0),
ties broken right-most first — so plain descending numbers reproduce "the far
end goes first". What survives keeps SOURCE ORDER in the bar, and the menu
lists the collapsed ones in source order too; nothing is ever reshuffled.
- Keep the trigger's own width independent of the count it reports: the glyph
never changes, the count rides in the accessible name ("More actions (5)"). A
trigger that grew from "2 more" to "12 more" would change the very budget that
produced the count.
- Degenerate cases: an empty `actions` array renders an empty bar and no
trigger; a container narrower than one control collapses everything and keeps
only the trigger; a container narrower than the trigger itself clips it rather
than dropping it, because losing the only route to the actions is worse than
losing a few pixels. Before the first measurement the split is "show
everything" — a state the layout effect guarantees is never painted.
- 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, and
anything that cannot fit is clipped instead of blowing the page out sideways.
Behavior — semantics, keyboard and refusal
- role="toolbar" + aria-orientation="horizontal" + aria-label on the root. The
whole bar is ONE tab stop (roving tabindex): ←/→ walk every control INCLUDING
the "More" trigger and the unavailable ones (wrapping), Home/End jump to the
ends, ↑/↓ are deliberately not intercepted so the page can still scroll.
Derive the active stop from the current split rather than storing it, so a
control that just collapsed hands the tab stop to a live sibling in the same
render.
- Unavailable controls carry `aria-disabled` and a guarded handler, NEVER the
`disabled` attribute — in the bar and in the menu alike. The browser blurs a
node the instant it becomes disabled, so an action that disables itself on
press drops the keyboard user on <body> mid-press. aria-disabled also keeps
the control focusable, hoverable and announced, which is how the reason gets
read out at all. Consequence in the menu: use aria-disabled on the row, not
the primitive's `disabled` prop, which would take the row out of the menu's
own keyboard walk and make the reason unreachable.
- `once` actions (Publish, Submit) are guarded by a ref that is READ AND WRITTEN
synchronously inside the handler; the matching state only paints the disabled
look afterwards. A state-only guard lets a double click through, because the
second click runs before the re-render.
- The menu is the shadcn dropdown-menu primitive (`modal={false}`), which brings
the portal, the flip/shift placement, Escape, outside-dismiss, typeahead and
focus return for free — so no `overflow: hidden` ancestor can clip it.
Collapsed toggles become menuitemcheckbox rows that keep their state and
preventDefault on select so the menu STAYS OPEN (reconfiguring three options
should take one trip); collapsed commands close it like any menu command; a
refused row preventDefaults too, because closing would look like it ran.
- When the last collapsed action returns, the trigger unmounts — close the menu
by adjusting state DURING RENDER, or a menu left `open` springs open by itself
the next time the container narrows. If focus was inside that menu it now has
nowhere to return to, so after the commit check for `document.activeElement ===
document.body` in a requestAnimationFrame (letting the menu's own restoration
go first) and hand focus to the current tab stop.
- Cleanup: the ResizeObserver, the window resize listener, the measurement rAF
and the focus-rescue rAF are all torn down on unmount and whenever the action
list changes; the fonts.ready promise is neutered by an `alive` flag.
Rendering & styling
- Semantic tokens only: text-muted-foreground for idle controls,
hover:bg-accent + hover:text-accent-foreground, bg-primary +
text-primary-foreground for a pressed toggle (on a monochrome palette an
accent tint is indistinguishable from hover, so pressed inverts instead),
bg-popover / text-popover-foreground for the menu, opacity-50 +
cursor-not-allowed for aria-disabled (a `disabled:` variant would never match
— there is no disabled attribute — and pointer-events must stay on so the
control can still be focused and announced). cn() merges the consumer
className onto the root, so a caller can add `rounded-lg border bg-card p-1`
and the padding is subtracted from the budget automatically.
- focus-visible:ring-2 ring-ring RING-INSET everywhere: the bar clips its own
overflow, and an outset ring on the last control would be sliced in half.
- NOTHING about a control's box may change with state. A pressed or unavailable
control changes colour and opacity only — a weight or padding change would
alter its own width and restart the maths on every click, and it would also
break the twin/real class sharing.
- Motion: colour transitions carry motion-reduce:transition-none and the menu's
entrance keyframes carry motion-reduce:[animation:none]. Nothing about the
collapse, the menu or the keyboard depends on animation.
Customization levers
- priority IS the collapse policy: give the two or three actions a user must
never lose the highest numbers and the destructive or rare ones the lowest.
Ties fold right-to-left, so plain descending numbers give you "the right-hand
end goes first" with no special casing.
- iconOnly, per action or as a bar-level default, is the single biggest lever on
how much fits before anything collapses; pair it with `shortcut`/`reason` so
the names stay discoverable.
- Density: SIZE's box / iconBox / row (gap) classes are the only inputs to how
much fits — loosen or tighten them and the split follows with no other change.
A third size is one entry in that map.
- Chrome: the bar itself is unstyled beyond the flex row; `className` turns it
into a card, a ghost strip or a floating pill (constrain the parent, not the
component — its width must come from outside).
- moreLabel / label rename the trigger and the bar for a localized or more
specific accessible name; swap MoreHorizontal for an ellipsis or chevron glyph
as long as the width stays constant across counts.
- onOverflowChange for a live "5 in the bar · 3 in the menu" readout, to persist
which controls a user actually sees, or to feed a customise-toolbar dialog.
- The action union is the extension point: a `separator` or `custom` member
means one more branch in the render pass and one rule in the split (a custom
slot has no honest representation as a menu row, so pin it inline).Concepts
- Measuring twins, not the painted bar — a hidden layer holds every action at its natural width, so all twelve widths are readable while the bar already shows only the seven that fit. There is never a frame in which the bar must be fully expanded in order to be measured, and the measurement can never be disturbed by its own result.
visibility: hidden, neverdisplay: none— adisplay: nonebox has no geometry at all, so it cannot be measured; that is the trap this pattern exists to avoid. The price is that a hidden box still contributes scrollable overflow, which is why the twins live inside a 0×0 clipping shell.- First paint is already collapsed — the pass runs in a layout effect, so the re-render lands before the browser paints. The user never sees the bar flash at full width and then fold, which is the tell of a passive-effect implementation.
- Priority collapse, source order kept — the lowest-
priorityaction leaves the row first (ties right-most first) and lands in the menu intact, still pressed or still unavailable; what survives never reshuffles. And because only the all-visible candidate is costed without the trigger, a bar that fits exactly renders no trigger, and hiding one narrow action to pay for a wider trigger is never chosen. - One tab stop, the trigger included — ←/→ walk every control and the "More" button alike, unavailable ones included, so nobody has to guess why a command is missing; the active stop is derived from the current split, so a control that just collapsed hands it to a live sibling instead of stranding focus.
- Refusal without
disabled— the native attribute blurs its own element and would throw a keyboard user back to<body>mid-press, so unavailable actions carryaria-disabledplus a handler guard, and one-shot actions add a ref that is read and written inside the same handler — a state-only guard lets a double click through.