# Toolbar (/docs/display/toolbar)



<ComponentShowcase name="toolbar" />

## Installation [#installation]

```bash
npx shadcn@latest add https://ui.zyeon.ai/r/toolbar.json
```

## Prompt [#prompt]

```text
Build a React + TypeScript + Tailwind "Toolbar" component using lucide-react
for the overflow glyphs and react-dom's createPortal for the floating layers.

Contract
- 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 [#concepts]

<Mermaid
  chart="`flowchart TD
A[&#x22;ResizeObserver on the bar<br/>+ items/size change + fonts.ready&#x22;] --> B[&#x22;rAF: one measurement pass&#x22;]
B --> C[&#x22;read content width, column-gap,<br/>rounded-up width of every mounted item&#x22;]
C --> D{&#x22;does the whole row fit?&#x22;}
D -- &#x22;yes&#x22; --> E[&#x22;overflow empty · no More trigger&#x22;]
D -- &#x22;no&#x22; --> F[&#x22;restart from all-visible,<br/>drop lowest priority first&#x22;]
F --> G[&#x22;tidy separators<br/>(leading / trailing / doubled)&#x22;]
G --> D
E --> H[&#x22;roving tabindex:<br/>one tab stop, ←/→/Home/End&#x22;]
F --> H
F --> I[&#x22;More trigger + portalled menu<br/>(fixed, opacity-0 until placed)&#x22;]
I --> J[&#x22;menu keeps disabled / pressed;<br/>Esc → focus back if isConnected&#x22;]`"
/>

* **Priority-based collapse** — the bar never truncates or wraps: the lowest-`priority` action 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`, never `disabled`** — 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.body` at `position: 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 `custom` item (a zoom readout, a page counter) has no honest representation as a menu row, so it is excluded from collapsing and always stays inline.
