Menubar
A desktop-app style horizontal menu bar (File / Edit / View / Help) built on the Base UI Menubar primitive — one tab stop, roving focus, hover-switch, submenus, and the full menu-item family shared with DropdownMenu.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
MenubarLabel,
MenubarShortcut,
} from "@tessinaui/ui";<Menubar>
<MenubarMenu value="file">
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem shortcut={["mod", "N"]}>New tab</MenubarItem>
<MenubarItem shortcut={["mod", "shift", "N"]}>New window</MenubarItem>
<MenubarSeparator />
<MenubarItem shortcut={["mod", "P"]}>Print…</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu value="edit">
<MenubarTrigger>Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem shortcut={["mod", "Z"]}>Undo</MenubarItem>
<MenubarItem shortcut={["mod", "shift", "Z"]}>Redo</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>Examples
Default
A horizontal menu bar with File, Edit, and View menus, each opening a dropdown of commands. shortcut={["mod", "N"]} resolves ⌘N on Apple keyboards and Ctrl+N everywhere else.
Variants
Four bar treatments — solid, outline, ghost, and floating.
Sizes
Five sizes from xs to xl scale triggers, padding, and dropdown content.
On color
tone="on-color" adapts the bar to a colored or dark plate via the surface ink contract. Inside a <Surface> the tone is inherited automatically — no prop needed.
Composition
The hybrid editor-bar pattern: put the shared chrome on a wrapper row, keep the Menubar itself ghost inside it, and place interactive extras (undo/redo buttons) beside the menubar — a role="menubar" may own only menu items, so a plain button inside it is an axe-critical violation. Give the extras the bar's radius (IconButton defaults to rounded="full") so every hit target on the strip hovers with the same corner; dividers are aria-hidden. Chevron-suffixed triggers are trailingIcon.
Icon triggers
Triggers take leadingIcon / trailingIcon; an icon-only trigger must carry an aria-label.
Item features
Items support leading icons, platform-resolving shortcuts, a badge slot, descriptions, a destructive error intent, and nested submenus.
Checkbox & radio items
Toggle options with MenubarCheckboxItem and single-select groups with MenubarRadioGroup. Both accept the full item anatomy — icons, shortcuts, descriptions.
Switch items
MenubarSwitchItem paints a toggle row as a switch — the spreadsheet / settings-menu idiom. Semantically it stays menuitemcheckbox, so the menu remains open on toggle.
Link items
MenubarLinkItem renders a real <a> — middle-click, ⌘-click, and the status-bar URL preview keep working.
Header & footer
MenubarHeader / MenubarFooter pin bands above and below the items — an account block, a "create new" row. The band itself is role="group", so an action inside it should be a real MenubarItem rather than a bare button: a menu row has to keep the family's hover fill, radius and row height, and a menuitem inside that group is legal ARIA.
Groups
Titled sections with MenubarGroup + MenubarLabel; the label names the group for assistive tech.
Disabled
Whole-bar disabled, per-trigger disabled, and per-item disabled. Unavailable actions dim — they are never hidden, so the menu keeps teaching what the app can do, and disabled items stay focusable for screen-reader discovery.
RTL
dir="rtl" flips arrow-key order, submenu sides, and chevrons. Left unset, the bar inherits the document direction.
Controlled
Control the open menu with value / onValueChange (null closes all).
Modal
modal locks page scroll while a menu is open; MenubarBackdrop adds an opt-in scrim. The house default is non-modal.
Loading
MenubarSkeleton mirrors the bar chrome while menu data loads.
When to use
Reach for Menubar when building desktop-style application shells — editors, design tools, IDE-like products. The pattern users expect: a horizontal strip of top-level triggers (File, Edit, View, Help), each opening a vertical dropdown with commands, shortcuts, separators, submenus.
- Website navigation (logo, nav items, mega-menus) →
NavigationMenu - A single dropdown on a button →
DropdownMenu - Right-click menus →
ContextMenu - Searchable command list →
Command— never embed a search field inside a menu - A button that also opens a menu →
SplitButton
Menu-writing guidance (HIG-derived): keep trigger titles to one word; append … to any item that needs more input before completing; group related items with separators; give icons to all items in a group or none; show/hide actions flip their label ("Show sidebar" ↔ "Hide sidebar") while persistent attributes use checkmarks; keep submenus one level deep and consider a new menu beyond ~5 nested items; dim unavailable items instead of removing them.
Behaviour
- Single open at a time — opening one menu closes any other
- Hover-switch — a closed bar never opens on hover; once any menu is open, hovering a sibling trigger switches to it (classic menubar UX, owned by the Base UI primitive)
- One tab stop — the whole bar is a single composite widget with a roving tabindex;
ArrowLeft/ArrowRightmove between triggers (RTL flips),loopwraps - Type-ahead — type a letter to focus the matching item inside an open menu
- Escape closes — and returns focus to the trigger
- On narrow viewports the bar scrolls horizontally; popups clamp to the viewport width. Labels that truncate should get a tooltip with the full text.
Accessibility
Implements the WAI-ARIA menubar pattern: role="menubar" on the bar, each trigger is a menuitem with aria-haspopup="menu" + aria-expanded, popups are role="menu", and items are menuitem / menuitemcheckbox / menuitemradio with aria-checked. Disabled items keep aria-disabled and stay focusable so they remain discoverable.
Keyboard
| Key | Context | Result |
|---|---|---|
Tab / Shift+Tab | anywhere | Enters/leaves the bar as one tab stop; with a menu open, closes it and moves on |
ArrowRight / ArrowLeft | on a trigger | Previous/next trigger (flipped in RTL; wraps per loop) |
Enter / Space / ArrowDown | on a trigger | Opens the menu, focus moves to the first item |
ArrowDown / ArrowUp | in a menu | Next / previous item |
Home / End | on a trigger | First / last trigger |
Home / End | in a menu | First / last item |
ArrowRight | on a submenu trigger | Opens the submenu (flipped in RTL) |
ArrowLeft | in a submenu | Closes it, focus returns to the parent item |
ArrowRight / ArrowLeft | on a leaf item | Closes this menu and opens the next / previous menubar menu |
Enter / Space | on an item | Activates it; the menu closes (checkbox/radio/switch rows stay open) |
Esc | in a menu | Closes it, focus returns to the trigger |
| printable character | in a menu | Type-ahead to the matching item |
Screen readers & AT matrix
Trigger names come from their label text — icon-only triggers must pass aria-label. MenubarShortcut keycaps carry spoken key names, so ["mod", "K"] announces "Command K" rather than a glyph. Item description text is part of the row's content, not a separate announcement.
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Pending — smoke pass scheduled with this release |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED — no Windows host available |
The component stays beta until at least one VoiceOver and one NVDA pass are recorded.
API Reference
Menubar props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Scales triggers, padding, gap, and dropdown content |
variant | "solid" | "outline" | "ghost" | "floating" | "solid" | Bar visual treatment — solid (filled), outline (bordered), ghost (no chrome), floating (bordered + shadow) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius — cascades to triggers, popups (full caps at the panel radius), and items |
tone | "default" | "on-color" | inherited | on-color adapts the bar to a colored/dark plate; defaults to the nearest Surface's tone |
dir | "ltr" | "rtl" | inherited | Explicit reading direction. Unset, the bar follows the document; set, it also drives Base UI's keyboard order and submenu sides |
value | string | null | — | Controlled open menu value |
defaultValue | string | null | null | Uncontrolled initial open menu value |
onValueChange | (value: string | null) => void | — | Called when the open menu changes |
loop | boolean | true | Wrap arrow-key focus from last to first trigger |
modal | boolean | false | Lock page scroll and outside interaction while a menu is open |
disabled | boolean | false | Disable the whole bar — triggers dim but stay visible |
intent | "none" | "primary" | "none" | Deprecated — renders unchanged for one minor, then removed |
MenubarMenu props
| Prop | Type | Description |
|---|---|---|
value | string | Unique identifier for this menu — used to control open state |
children | ReactNode | A MenubarTrigger followed by a MenubarContent |
MenubarTrigger props
| Prop | Type | Description |
|---|---|---|
leadingIcon | ReactNode | Icon before the label (sized per bar size) |
trailingIcon | ReactNode | Icon after the label — e.g. a chevron |
disabled | boolean | Disables this trigger |
Renders a <button role="menuitem">. Accepts all standard button HTML attributes. Icon-only triggers must pass aria-label.
MenubarContent props
| Prop | Type | Default | Description |
|---|---|---|---|
size | MenubarSize | inherited | Override dropdown size — defaults to the bar's size |
width | "narrow" | "default" | "wide" | "default" | Constrain dropdown width |
side | "top" | "bottom" | "left" | "right" | "bottom" | Anchor side |
align | "start" | "center" | "end" | "start" | Anchor alignment |
sideOffset | number | 6 | Pixel offset from the trigger |
alignOffset | number | 0 | Alignment offset |
collisionPadding | number | 8 | Minimum gap from viewport edges before flipping/shifting |
positionerClassName | string | — | Extra classes on the positioner (e.g. raise z-50) |
portal / container | boolean / HTMLElement | true / document.body | Portal control for previews and embedded shells |
inert | boolean | — | Static, non-interactive popup for illustrations |
Popups scroll when taller than the available space and never exceed the viewport width. Corner radius follows the bar's rounded.
MenubarItem props
| Prop | Type | Description |
|---|---|---|
inset | boolean | Reserve space for an indicator on the start side |
leadingIcon | ReactNode | Icon before the label |
trailingIcon | ReactNode | Icon after the label |
shortcut | string | string[] | Keyboard shortcut hint. Prefer the array form — ["mod", "K"] resolves ⌘K / Ctrl+K per platform. A literal string ("⌘K") still works |
badge | ReactNode | Status pill beside the label — "New", "Beta", a count. Pass a <Badge> |
description | string | Secondary line below the label |
intent | "none" | "error" | error styles the item as destructive |
disabled | boolean | Disables the item (stays visible and focusable) |
closeOnClick | boolean | Whether activating closes the menu. Default true |
onClick | (event) => void | Called when activated |
MenubarLinkItem props
All MenubarItem anatomy props on a real <a> — href, target, rel are native. closeOnClick defaults to false: a plain click navigates (the unload closes the menu) while modified clicks open a tab and leave it open. SPA route changes that do not unload should pass closeOnClick explicitly.
MenubarCheckboxItem / MenubarRadioItem props
checked + onCheckedChange (checkbox) or group value + onValueChange with per-item value (radio), plus the full item anatomy — leadingIcon, trailingIcon, shortcut, badge, description. Both default closeOnClick to false, so multi-toggling keeps the menu open.
MenubarSwitchItem props
A MenubarCheckboxItem painted as a switch (leadingIcon, description, checked, onCheckedChange). The switch is presentation only — role, keyboard, and announcements are the checkbox item's.
MenubarHeader / MenubarFooter props
Non-interactive bands pinned above/below the items (sticky and separator props, both true by default). Rendered as role="group" — the only div shape legal inside role="menu".
MenubarSub / MenubarSubTrigger / MenubarSubContent
Composed exactly like the parent — MenubarSub wraps the pair, MenubarSubTrigger shows a chevron (auto-flipped in RTL) and accepts inset + leadingIcon, MenubarSubContent opens to the logical side.
MenubarLabel, MenubarSeparator, MenubarShortcut, MenubarGroup, MenubarBackdrop
Layout primitives — MenubarLabel for section headings (associates with its MenubarGroup via aria-labelledby), MenubarSeparator for hairlines, MenubarShortcut for inline shortcut keycaps (also auto-rendered by the shortcut prop; accepts string | string[], glyphs, platform), MenubarGroup for grouping, MenubarBackdrop for the opt-in scrim under a modal bar.
Notes
- Built on the Base UI
Menubarprimitive plusMenuparts: roving tabindex, hover-switch, arrow navigation, and type-ahead are the primitive's own behavior, not re-implemented here. - The popup family (content, items, labels, header/footer, skeleton internals) is DropdownMenu's — one implementation, two names — so the two menus can never drift apart. Anything documented for
DropdownMenuitems applies verbatim. - Each
MenubarMenuis a controlledMenu.Rootdriven by the bar's open value; liftvalue/onValueChangeto fully control the bar. data-slotattributes (menubar,menubar-trigger,menu-content,menu-item, …) are stamped on every part for testing and agent targeting.