Menu Bar
A desktop application menubar — one click opens, hover then travels between menus, with submenus, shortcut hints, checkable rows and a full roving keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/menu-bar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MenuBar" component: a desktop application
menubar (File / Edit / View / Help) on top of the Radix `Menubar` primitives from
the `radix-ui` package, with lucide-react for the check, the radio dot and the
submenu chevron.
Contract
- forwardRef<HTMLDivElement> — the ref and every native prop that is not listed
below land on the role="menubar" row, and className is merged onto it with cn().
- Props extend Omit<React.HTMLAttributes<HTMLDivElement>, "children" |
"defaultValue" | "dir"> — children because the bar is described by data,
defaultValue and dir because the primitive redefines both — and add:
menus: MenuBarMenu[], label?: string (default "Application menu"),
value?: string (controlled id of the open menu, "" is closed),
onValueChange?: (value: string) => void, loop?: boolean (default true),
dir?: "ltr" | "rtl", emptyLabel?: string (default "Nothing here yet").
- MenuBarMenu = { id, label, disabled?, items: MenuBarNode[] }.
- MenuBarNode is a discriminated union on `kind`:
{ kind: "item", id, label, shortcut?, icon?, disabled?, destructive?, onSelect? }
| { kind: "checkbox", id, label, shortcut?, checked, onCheckedChange, disabled? }
| { kind: "radio", id, label?, value, onValueChange,
options: { value, label, shortcut?, disabled? }[] }
| { kind: "submenu", id, label, icon?, disabled?, items: MenuBarNode[] }
| { kind: "separator", id }
| { kind: "label", id, label }.
- A `shortcut` is a hint, not a binding: it is printed right-aligned and read as
part of the row's name, and the real key handler stays the consumer's. Same for
onSelect — the bar performs nothing by itself.
- onValueChange reports the id the bar SETTLED on, which is not always the id it
was asked for: an unavailable menu is skipped past or refused first.
Behavior
- Opening: the first click opens a menu; after that the bar owns the pointer and
hovering another title switches to it with no second click. Clicking the open
title closes it. That much is the primitive's; everything below is the layer
the wrapper adds.
- Keyboard on the bar: ArrowLeft / ArrowRight move between titles (wrapping when
loop), Home / End jump to the ends, Enter / Space / ArrowDown open the focused
menu, Tab leaves the bar entirely — the whole row is ONE tab stop.
- Typeahead on the bar: the primitive has typeahead inside a menu and none across
the titles, so add it. Printable keys accumulate into a buffer that expires
1000ms after the last keystroke; a repeated single letter walks the matches
(pressing "v" twice visits two V menus), a longer buffer is a prefix; ignore
Space and any key with a modifier. Clear the timer on unmount. Keys that came
from an open menu must be ignored, because portalled content bubbles through
the REACT tree and arrives at the bar's handler as well — gate on
event.currentTarget.contains(event.target), which is DOM containment and so is
false for a portal.
- Keyboard inside a menu: ArrowDown / ArrowUp move, Home / End jump, typeahead
matches rows, ArrowRight opens a submenu (and otherwise walks to the next menu),
ArrowLeft closes a submenu (and otherwise walks to the previous one), Escape
closes exactly one level and hands focus back to the title. Pass
textValue={label} to every row so the shortcut hint sharing its text content
cannot pollute the typeahead match.
- Unavailable ROWS use the primitive's `disabled`, which is aria-disabled on a
div — never a native attribute. Do not add pointer-events-none: the refusal is
already enforced in the handlers, and a row that swallows pointer events is a
hole in the menu instead of something that can be hovered and inspected.
- An unavailable MENU cannot use that door, because a title is a real <button>
and the native disabled attribute blurs it to <body> the instant a permission
flips under someone standing on it. So never pass `disabled` to the trigger.
Mark it aria-disabled and refuse in the handlers instead: preventDefault the
pointerdown and then focus the title deliberately (a grey title you can land on
is a title a screen reader can announce), preventDefault Enter / Space /
ArrowDown, and preventDefault the pointerenter that would otherwise pull the
open menu's focus onto it while hovering. The primitive composes handlers with
a defaultPrevented check, so preventing the default is exactly what cancels its
own behaviour.
- The arrow walk must not dead-end on that grey title, or every press would land
on it again. Control the primitive's open value: when it asks for an
unavailable menu, decide whether the request is the next or the previous
neighbour of the currently open one — the only two shapes an arrow can produce
— and keep hopping that way until an available menu turns up, honouring loop at
the ends. Any other request (a click, a hover jump, a keypress while the bar is
closed) is refused and the current menu stays where it is.
- Normalise the open value on every render:
menus.some(m => m.id === value && !m.disabled) ? value : "". Without it a menu
deleted or disabled while open leaves the bar toggling against a ghost — one
click to clear the stale id, a second to finally open something.
- One activation per opening. Holding Enter auto-repeats into the highlighted
row's click handler long before the close has rendered, so guard the consumer
callbacks (onSelect, onCheckedChange, the radio group's onValueChange) with a
ref read AND written in the same tick as the activation, released in an effect
keyed on the open menu — never while a menu stays open, which is the window the
lock exists for. Stop the repeat one layer earlier as well: on every row,
preventDefault a keydown whose event.repeat is true for Enter or Space.
- A row may delete the menu it lives in ("Hide the Develop menu"). Its content
unmounts and the primitive cannot hand focus back, because the title it would
return to went with it — focus falls to the document body. Remember the id the
reader was last standing in (set it on trigger focus and on every open) and, in
an effect keyed on the joined list of menu ids, when that id is gone AND
document.activeElement is the body, focus the surviving title at the same
index, clamped to the end of the row. Check the body first: if focus went
somewhere real, it is not yours to move.
- A menu with no rows renders emptyLabel instead of an empty 4px box; so does an
empty submenu.
- Panels are portalled, so a bar living in a card, a panel header or a scroller
with overflow hidden is never clipped, and each panel is capped at
var(--radix-menubar-content-available-height) so a long menu scrolls instead of
spilling off a short viewport.
Rendering & styling
- Semantic tokens only: the bar is bg-card inside a border, a title goes
bg-accent / text-accent-foreground while highlighted or open, panels are
bg-popover / text-popover-foreground with a border and a shadow, rows highlight
on accent, shortcut hints and group headings are text-muted-foreground,
destructive rows are text-destructive over bg-destructive/10, and an
unavailable title is text-muted-foreground at 60% opacity.
- cn() merges every className. focus-visible:ring-2 ring-ring on the titles;
inside a menu the primitive moves focus itself, so data-[highlighted] is what
paints a row.
- Every row of a list that contains any checkbox, radio or icon gets the same
left gutter, with the check / dot / icon absolutely placed inside it, so labels
line up whether or not a given row carries a mark.
- Motion is decoration, and it is an ENTRANCE only: a fade plus a 95% zoom plus a
one-unit slide from the anchored side, all behind motion-safe. Give the panel an
exit animation and travel breaks — the closing panel stays mounted for the
length of it, and its dismissable layer reads the panel that just opened taking
focus as focus leaving it, so hovering or arrowing to the next title dismisses
the whole bar instead of switching menus. Gate on motion-safe rather than
motion-reduce:animate-none, too: a data-state variant outranks a bare one, so
that override never wins. With motion off the menu simply appears.
- ARIA: role="menubar" with an aria-label on the row; each title is a
role="menuitem" button carrying aria-haspopup="menu", aria-expanded and
aria-controls; each panel is a role="menu" labelled by its title; rows are
role="menuitem" / "menuitemcheckbox" / "menuitemradio" with aria-checked; a
radio group is a role="group" named by its own heading through aria-labelledby.
Customization levers
- Density: titles are px-2.5 py-1 and rows px-2 py-1.5 — take one step off both
for a compact 28px bar. Nothing is measured in JS, so the layout just follows.
- Sub-blocks are independent: drop the shortcut hint, the icon slot or the gutter
and the rest keeps working. The union is the only thing to extend when you need
a new row type (a recent-files header, an avatar row, a colour swatch).
- Placement: align="start" with sideOffset 6 anchors a panel under its title —
raise the offset for a floating bar, or switch to align="end" for a bar pinned
to the right edge.
- Tokens: paint the open title with bg-primary / text-primary-foreground for a
branded bar, or drop the border and bg-card to let the row sit straight on an
app chrome strip.
- Behaviour: `loop` decides whether the arrow walk wraps at the ends. To keep a
menu open while several checkboxes are toggled, widen that row's callback to
receive the primitive's select event and preventDefault() it — closing on
toggle is the desktop default, not a requirement.
- The data model is the extension point: menus are derived from your own state,
so permissions ("Develop" unavailable), feature flags (a menu that is not there
at all) and recent-file lists are ordinary rendering, not special cases.Concepts
- Hover travel after the first click — a menubar is modal about the pointer: one click arms the whole row, and from then on moving across a title is enough to switch menus, which is what makes browsing twelve commands cost one press instead of twelve.
- One tab stop — the row is a single stop in the tab order; ArrowLeft / ArrowRight, Home / End and first-letter typeahead move inside it, so a keyboard user tabs past an entire application menu rather than through it.
- Refusal without the native attribute — a title is a real button, so
disabledwould blur it to the document body the moment a permission flips; the unavailable state isaria-disabledplus guards in the pointer and key handlers, which keeps the title reachable and announceable. - Skip, never dead-end — an arrow walk that reaches an unavailable title carries on in the same direction instead of stopping, because refusing in place would mean pressing the same key forever against the same grey word.
- One activation per opening — a held Enter auto-repeats into the highlighted row long before the close has rendered, so the callback is behind a ref read and written in the same tick, released only when the open menu changes.
- Focus handoff on removal — a row that hides its own menu takes the title focus would have returned to with it, so the bar remembers where the reader was standing and aims at a surviving title when the removal leaves focus on the body.
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.
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.