Navigation

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useRovingTabindex } from "@/registry/hooks/use-roving-tabindex"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/toolbar-overflow.json

Prompt

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, never display: none — a display: none box 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-priority action 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 carry aria-disabled plus 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.

On This Page