Menubar
A desktop-style row of menus for editors with many commands.
- Status
- Experimental
- Level
- Organism
- Category
- Navigation
- Adoption
- Not used yet
import { Menubar } from "@oration/canon/components/menubar";packages/canon/src/components/menubar.tsximport { Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger,} from "@oration/canon/components/menubar";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const [rulers, setRulers] = React.useState(true); const [fieldNames, setFieldNames] = React.useState(false); const [zoom, setZoom] = React.useState("100"); return ( <div className="flex w-full max-w-xl flex-col gap-3 rounded-xl bg-card p-3 shadow-border"> <Menubar aria-label="Remittance template editor"> <MenubarMenu> <MenubarTrigger>File</MenubarTrigger> <MenubarContent className="w-56"> <MenubarItem onClick={() => toast.add({ title: "New remittance template" }) } > New template <MenubarShortcut>⌘N</MenubarShortcut> </MenubarItem> <MenubarItem onClick={() => toast.add({ title: "Template duplicated", description: "Northwind Freight remittance (copy)", }) } > Duplicate <MenubarShortcut>⌘D</MenubarShortcut> </MenubarItem> <MenubarSeparator /> <MenubarSub> <MenubarSubTrigger>Share</MenubarSubTrigger> <MenubarSubContent> <MenubarItem onClick={() => toast.add({ title: "Link copied" }) } > Copy link </MenubarItem> <MenubarItem onClick={() => toast.add({ title: "Sent to Priya Raman for review", }) } > Send to Priya Raman </MenubarItem> </MenubarSubContent> </MenubarSub> <MenubarItem onClick={() => toast.add({ type: "success", title: "Exported as PDF", }) } > Export as PDF <MenubarShortcut>⇧⌘E</MenubarShortcut> </MenubarItem> </MenubarContent> </MenubarMenu> <MenubarMenu> <MenubarTrigger>Edit</MenubarTrigger> <MenubarContent className="w-56"> <MenubarItem onClick={() => toast.add({ title: "Undid last change" }) } > Undo <MenubarShortcut>⌘Z</MenubarShortcut> </MenubarItem> <MenubarItem disabled> Redo <MenubarShortcut>⇧⌘Z</MenubarShortcut> </MenubarItem> <MenubarSeparator /> <MenubarSub> <MenubarSubTrigger>Insert field</MenubarSubTrigger> <MenubarSubContent className="w-44"> {[ "Supplier name", "Invoice number", "Amount paid", "Payment date", ].map((field) => ( <MenubarItem key={field} onClick={() => toast.add({ title: `Inserted ${field}`, }) } > {field} </MenubarItem> ))} </MenubarSubContent> </MenubarSub> </MenubarContent> </MenubarMenu> <MenubarMenu> <MenubarTrigger>View</MenubarTrigger> <MenubarContent className="w-52"> <MenubarCheckboxItem checked={rulers} onCheckedChange={setRulers} > Show rulers </MenubarCheckboxItem> <MenubarCheckboxItem checked={fieldNames} onCheckedChange={setFieldNames} > Show field names </MenubarCheckboxItem> <MenubarSeparator /> <MenubarGroup> <MenubarLabel inset className="text-xs text-muted-foreground" > Zoom </MenubarLabel> <MenubarRadioGroup value={zoom} onValueChange={setZoom} > {["75", "100", "125"].map((level) => ( <MenubarRadioItem key={level} value={level}> {level}% </MenubarRadioItem> ))} </MenubarRadioGroup> </MenubarGroup> </MenubarContent> </MenubarMenu> </Menubar> <div className="rounded-[10px] bg-muted/70 px-3 py-2 text-xs text-muted-foreground tabular-nums"> Rulers {rulers ? "on" : "off"}, field names{" "} {fieldNames ? "on" : "off"}, zoom {zoom}% </div> </div> );}Usage#
Menubar is a desktop-style row of menus (File, Edit, View) for editors with more commands than a toolbar can hold. It's built on Base UI Menubar, and each menu reuses Dropdown menu, so items, checkboxes, radios, submenus and shortcuts behave the same way. Nothing in Oration uses it yet: the workspace's editors get by with toolbars and a single More menu. Reach for it only when an editor has several groups of commands that people will learn by heading; most screens want a toolbar.
When to use
- For a document-style editor with many commands in familiar groups: a remittance template, an email template or a report layout.
- When the commands need keyboard shortcuts shown beside them and people expect to find them under File, Edit and View.
- For view options that are toggles or a single choice, as checkbox and radio items.
When not to use
- For a few actions on a page or record. Show them as buttons, with the rest in one menu. Use Toolbar
- For a single overflow menu behind a button. Use Dropdown menu
- For navigation between pages. Use Navigation menu
- For commands people search for by name. Use Command menu
- For right-click actions on an object. Use Context menu
Headings people already know
Destructive is a tint
Anatomy#
- Bar.
Menubar: a 32px row with a hairline border, 10px corners and 3px padding. - Trigger.
MenubarTrigger: a 14px medium label; Well Gray on hover and while its menu is open. - Menu.
MenubarContent: Popover White, 10px corners, 4px padding, 8px below the bar and nudged 4px left. - Item.
MenubarItem: a 14px row with 8px corners; Menu Hover when highlighted. - Shortcut.
MenubarShortcut: right-aligned 12px Slate Meta keys. - Indicator. Checkbox and radio items reserve 28px on the left for a check.
- Submenu.
MenubarSubTriggerwith a trailing chevron opensMenubarSubContentto the right.
Examples#
Checkbox and radio items
Toggles for optional columns and a single choice for density. Both stay open when chosen, so people can set several at once.
Showing 2 optional columns, default density
import { Menubar, MenubarCheckboxItem, MenubarContent, MenubarMenu, MenubarRadioGroup, MenubarRadioItem, MenubarTrigger,} from "@oration/canon/components/menubar";import * as React from "react";export function CheckboxAndRadio() { const [columns, setColumns] = React.useState({ dueDate: true, approver: true, glCode: false, }); const [density, setDensity] = React.useState("default"); return ( <div className="flex flex-col items-start gap-3"> <Menubar aria-label="Invoice list"> <MenubarMenu> <MenubarTrigger>Columns</MenubarTrigger> <MenubarContent className="w-48"> <MenubarCheckboxItem checked={columns.dueDate} onCheckedChange={(checked) => setColumns((c) => ({ ...c, dueDate: checked })) } > Due date </MenubarCheckboxItem> <MenubarCheckboxItem checked={columns.approver} onCheckedChange={(checked) => setColumns((c) => ({ ...c, approver: checked })) } > Approver </MenubarCheckboxItem> <MenubarCheckboxItem checked={columns.glCode} onCheckedChange={(checked) => setColumns((c) => ({ ...c, glCode: checked })) } > GL code </MenubarCheckboxItem> </MenubarContent> </MenubarMenu> <MenubarMenu> <MenubarTrigger>Density</MenubarTrigger> <MenubarContent className="w-44"> <MenubarRadioGroup value={density} onValueChange={setDensity} > <MenubarRadioItem value="compact"> Compact </MenubarRadioItem> <MenubarRadioItem value="default"> Default </MenubarRadioItem> <MenubarRadioItem value="comfortable"> Comfortable </MenubarRadioItem> </MenubarRadioGroup> </MenubarContent> </MenubarMenu> </Menubar> <p className="text-xs text-muted-foreground"> Showing {Object.values(columns).filter(Boolean).length} optional columns, {density} density </p> </div> );}Labels, shortcuts and destructive items
A group label names the run, shortcuts sit at the right, and the destructive item goes last behind a separator with variant="destructive".
import { Menubar, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarSeparator, MenubarShortcut, MenubarTrigger,} from "@oration/canon/components/menubar";import { toast } from "@oration/canon/components/toast";export function Destructive() { return ( <Menubar aria-label="Payment run"> <MenubarMenu> <MenubarTrigger>Run</MenubarTrigger> <MenubarContent className="w-60"> <MenubarGroup> <MenubarLabel className="text-xs text-muted-foreground"> Friday, Oct 2 run </MenubarLabel> <MenubarItem onClick={() => toast.add({ type: "success", title: "Run approved", description: "212 invoices, $684,210.00", }) } > Approve run <MenubarShortcut>⌘↵</MenubarShortcut> </MenubarItem> <MenubarItem onClick={() => toast.add({ title: "Moved to Monday, Oct 5" }) } > Reschedule </MenubarItem> </MenubarGroup> <MenubarSeparator /> <MenubarItem variant="destructive" onClick={() => toast.add({ type: "error", title: "Payment run canceled", }) } > Cancel run </MenubarItem> </MenubarContent> </MenubarMenu> <MenubarMenu> <MenubarTrigger>Export</MenubarTrigger> <MenubarContent className="w-48"> <MenubarItem onClick={() => toast.add({ title: "Exported NACHA file" }) } > NACHA file </MenubarItem> <MenubarItem onClick={() => toast.add({ title: "Exported CSV" })} > CSV </MenubarItem> </MenubarContent> </MenubarMenu> </Menubar> );}States#
import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";export function StatesMatrix() { return ( <div className="flex flex-wrap items-start justify-center gap-8"> <div className="flex flex-col items-center gap-3"> <div className="flex h-8 items-center gap-0.5 rounded-lg border p-[3px]"> <span className="flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium"> Rest </span> <span className="flex items-center rounded-sm bg-muted px-1.5 py-[2px] text-sm font-medium"> Hover </span> <span className="flex items-center rounded-sm bg-muted px-1.5 py-[2px] text-sm font-medium"> Open </span> </div> <span className="text-xs text-muted-foreground">Triggers</span> </div> <div className="flex flex-col items-center gap-3"> <div className="w-60 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"> {itemStates.map((state) => ( <span key={state.label} className={cn( "flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm", state.className, )} > {state.label} </span> ))} <span className="relative flex items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm"> <CheckIcon aria-hidden="true" className="absolute left-1.5 size-4" /> Checked </span> </div> <span className="text-xs text-muted-foreground">Items</span> </div> </div> );}| State | Treatment |
|---|---|
| Rest | Ink labels on the bar. |
| Trigger hover | Well Gray fill. Once a menu is open, hovering another trigger opens it. |
| Trigger open | aria-expanded: Well Gray fill while its menu shows. |
| Item highlighted | Menu Hover fill under pointer or keyboard focus. |
| Checked | A 16px check in the left gutter; radios show the same check. |
| Disabled | 50% opacity, skipped by pointer; still announced. |
| Destructive | Red text; highlighted, a 10% red tint (20% in dark). |
| Submenu open | The sub-trigger keeps the Menu Hover fill. |
Behavior#
- Click a trigger, or focus it and press Enter, Space or Down, to open its menu. While one menu is open, hovering or arrowing to another trigger switches menus.
- Focus moves between triggers with the arrow keys and loops at the ends (
loopFocus, on by default). - Menus are modal by default (
modal): the page behind doesn't scroll and outside clicks close the menu first. - Items close the menu when chosen. Checkbox and radio items toggle in place; control them with
checkedandonCheckedChange, orvalueandonValueChangeon the radio group. - Menus open in about 100ms with a fade, a 0.95 zoom and a short slide from the bar. Submenus fade and zoom both ways; top-level menus have no exit animation.
- Escape closes the open menu and returns focus to its trigger.
Do and don't#
Content#
- Headings are single words: File, Edit, View.
- Items start with a verb or name the setting: Export as PDF, Insert field, Show rulers.
- Name destructive items for what they remove: Cancel run, Delete template.
Accessibility#
- The bar has
role="menubar"and each trigger is amenuitemwitharia-haspopupandaria-expanded. Give the bar anaria-labelthat names what it edits. - Only one trigger is in the tab order; arrows move between the rest (roving focus).
- Checkbox and radio items expose
menuitemcheckboxandmenuitemradiowitharia-checked. - A
MenubarLabelmust sit inside aMenubarGroupso it labels that group. - Shortcuts are shown, not bound: wire the keys up yourself and keep them in sync with the label.
- Triggers draw no focus ring; see known gaps.
| Keys | Action |
|---|---|
| Tab | Moves focus to the bar's current trigger, then out of the bar. |
| ←→ | Moves between triggers, or between open menus. |
| Enter | Opens the focused menu, or runs the highlighted item. |
| Space | Opens the focused menu, or toggles a checkbox item. |
| ↓↑ | Opens the menu, then moves through its items. |
| → | Opens a submenu from its trigger. |
| Esc | Closes the menu and focuses its trigger. |
Design tokens#
| Token | Used for |
|---|---|
--border | The bar's hairline and separators |
--muted | Trigger hover and open fill |
--accent | Menu Hover, the highlighted item |
--accent-foreground | Text on the highlight |
--popover | Menu background |
--destructive | Destructive text and its 10% and 20% tints |
--muted-foreground | Shortcuts and labels |
shadow-md, ring-foreground/10 | Menu edge and lift |
--radius-lg | 10px bar and menu corners |
API reference#
Menubar
The bar.
Other props spread onto Base UI Menubar (<div role="menubar">).
| Prop | Type | Default | Description |
|---|---|---|---|
modal | boolean | true | Locks page scroll and outside interaction while a menu is open. |
disabled | boolean | false | Disables every menu. |
orientation | "horizontal" | "vertical" | "horizontal" | Arrow-key direction between triggers. |
loopFocus | boolean | true | Wraps focus from the last trigger to the first. |
MenubarMenu
One menu: a Dropdown menu root.
Other props spread onto Base UI Menu.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controlled open state. |
onOpenChange | (open, eventDetails) => void | No default | Called when it opens or closes. |
MenubarTrigger
The menu's heading in the bar.
Other props spread onto Base UI Menu.Trigger.
No props of its own.
MenubarContent
The menu popup.
Other props spread onto Dropdown menu content (Base UI Menu.Popup and positioner).
| Prop | Type | Default | Description |
|---|---|---|---|
align | "start" | "center" | "end" | "start" | Alignment to the trigger. |
alignOffset | number | -4 | Nudges the menu to line up with the bar's padding. |
side | "top" | "bottom" | "left" | "right" | "bottom" | Side of the trigger. |
sideOffset | number | 8 | Gap below the bar. |
className | string | No default | Set a width such as w-56; the default tracks the trigger. |
MenubarItem
A command.
Other props spread onto Base UI Menu.Item.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "destructive" | "default" | Red text and tint for destructive commands. |
inset | boolean | No default | Indents the label 28px to line up with checkbox items. |
disabled | boolean | false | Dims and skips the item. |
onClick | (event) => void | No default | Runs the command. |
MenubarCheckboxItem
A toggle with a check in the gutter.
Other props spread onto Base UI Menu.CheckboxItem.
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | No default | Controlled state. |
defaultChecked | boolean | No default | Uncontrolled initial state. |
onCheckedChange | (checked: boolean, eventDetails) => void | No default | Called on toggle. |
inset | boolean | No default | Kept for parity; checkbox items are always inset. |
MenubarRadioGroup
Groups radio items into one choice.
Other props spread onto Base UI Menu.RadioGroup.
| Prop | Type | Default | Description |
|---|---|---|---|
value | any | No default | Controlled value. |
onValueChange | (value, eventDetails) => void | No default | Called when the choice changes. |
MenubarRadioItem
One option, checked when it matches the group's value.
Other props spread onto Base UI Menu.RadioItem.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | any | No default | The option's value. |
inset | boolean | No default | Kept for parity; radio items are always inset. |
MenubarGroup, MenubarLabel
A group of items and its 14px medium label.
Other props spread onto Base UI Menu.Group, Menu.GroupLabel.
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | No default | Indents the label to line up with checkbox items. |
MenubarSeparator
A hairline across the menu with 4px above and below.
Other props spread onto Base UI Menu.Separator.
No props of its own.
MenubarShortcut
Right-aligned key hint.
Other props spread onto <span>.
No props of its own.
MenubarSub, MenubarSubTrigger, MenubarSubContent
A nested menu, its trigger (with a chevron) and its popup to the right.
Other props spread onto Base UI Menu.SubmenuRoot, Menu.SubmenuTrigger, Dropdown menu content.
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | No default | On the trigger: indents the label. |
MenubarPortal
The Dropdown menu portal, for custom popups.
Other props spread onto Base UI Menu.Portal.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Nothing in apps/web imports it, so its styling hasn't met a real screen.
MenubarTrigger is outline-hidden with no focus-visible style, so a keyboard user can't see which trigger is focused until a menu opens.
The bar draws a CSS border, and triggers use rounded-sm with 2px vertical padding. DESIGN.md has no menubar, but controls elsewhere are 28 or 32px with 8px corners.
MenubarContent inherits Dropdown menu's w-(--anchor-width), so a menu is only as wide as min-w-36 unless you set a width.
Menus use shadow-md and shadow-lg with a ring, not the overlay shadow, open in 100ms rather than 160ms, and the top-level menu has no exit animation.
MenubarShortcut letter-spaces its keys (tracking-widest), which Canon avoids elsewhere; Kbd is the shared key style.
MenubarLabel is 14px medium ink, where Dropdown menu's label is 12px Slate Meta; the demos set it to match.