Skip to content

Menubar

A desktop-style row of menus for editors with many commands.

Category
Navigation
Adoption
Not used yet
import { Menubar } from "@oration/canon/components/menubar";
packages/canon/src/components/menubar.tsx
Rulers on, field names off, zoom 100%
import {  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

Menus are named the way desktop apps name them: File, Edit, Insert, View, Help. Invent a heading only for a group with no conventional home.

Destructive is a tint

A destructive item is red text on a 10% red highlight, set apart by a separator at the end of its menu, and named for what it removes.

Anatomy#

FileEditView
New template⌘NDuplicate⌘DShow rulersShare
  1. Bar. Menubar: a 32px row with a hairline border, 10px corners and 3px padding.
  2. Trigger. MenubarTrigger: a 14px medium label; Well Gray on hover and while its menu is open.
  3. Menu. MenubarContent: Popover White, 10px corners, 4px padding, 8px below the bar and nudged 4px left.
  4. Item. MenubarItem: a 14px row with 8px corners; Menu Hover when highlighted.
  5. Shortcut. MenubarShortcut: right-aligned 12px Slate Meta keys.
  6. Indicator. Checkbox and radio items reserve 28px on the left for a check.
  7. Submenu. MenubarSubTrigger with a trailing chevron opens MenubarSubContent to 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#

RestHoverOpen
Triggers
RestHighlightedDisabledDestructiveDestructive, highlightedChecked
Items
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>    );}
States
StateTreatment
RestInk labels on the bar.
Trigger hoverWell Gray fill. Once a menu is open, hovering another trigger opens it.
Trigger openaria-expanded: Well Gray fill while its menu shows.
Item highlightedMenu Hover fill under pointer or keyboard focus.
CheckedA 16px check in the left gutter; radios show the same check.
Disabled50% opacity, skipped by pointer; still announced.
DestructiveRed text; highlighted, a 10% red tint (20% in dark).
Submenu openThe 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 checked and onCheckedChange, or value and onValueChange on 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#

FileEditInsertView
Do. Use a menubar when an editor has four or more groups of commands under familiar headings.
ApproveExport
Don't. Put two or three actions in a menubar. They're buttons; a bar of one-item menus hides them behind an extra click.
Do. Show the shortcut beside every command that has one, in the same notation as the rest of the suite (⌘, ⇧, ↵).
Don't. Invent shortcuts that clash with the browser or the suite, such as ⌘K or ⌘B.

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 a menuitem with aria-haspopup and aria-expanded. Give the bar an aria-label that names what it edits.
  • Only one trigger is in the tab order; arrows move between the rest (roving focus).
  • Checkbox and radio items expose menuitemcheckbox and menuitemradio with aria-checked.
  • A MenubarLabel must sit inside a MenubarGroup so 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.
Keyboard interactions
KeysAction
TabMoves focus to the bar's current trigger, then out of the bar.
←→Moves between triggers, or between open menus.
EnterOpens the focused menu, or runs the highlighted item.
SpaceOpens the focused menu, or toggles a checkbox item.
↓↑Opens the menu, then moves through its items.
→Opens a submenu from its trigger.
EscCloses the menu and focuses its trigger.

Design tokens#

Design tokens
TokenUsed for
--borderThe bar's hairline and separators
--mutedTrigger hover and open fill
--accentMenu Hover, the highlighted item
--accent-foregroundText on the highlight
--popoverMenu background
--destructiveDestructive text and its 10% and 20% tints
--muted-foregroundShortcuts and labels
shadow-md, ring-foreground/10Menu edge and lift
--radius-lg10px bar and menu corners

API reference#

Menubar

The bar.

Other props spread onto Base UI Menubar (<div role="menubar">).

Props of Menubar
PropTypeDefaultDescription
modalbooleantrueLocks page scroll and outside interaction while a menu is open.
disabledbooleanfalseDisables every menu.
orientation"horizontal" | "vertical""horizontal"Arrow-key direction between triggers.
loopFocusbooleantrueWraps focus from the last trigger to the first.

MenubarMenu

One menu: a Dropdown menu root.

Other props spread onto Base UI Menu.Root.

Props of MenubarMenu
PropTypeDefaultDescription
openbooleanNo defaultControlled open state.
onOpenChange(open, eventDetails) => voidNo defaultCalled 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).

Props of MenubarContent
PropTypeDefaultDescription
align"start" | "center" | "end""start"Alignment to the trigger.
alignOffsetnumber-4Nudges the menu to line up with the bar's padding.
side"top" | "bottom" | "left" | "right""bottom"Side of the trigger.
sideOffsetnumber8Gap below the bar.
classNamestringNo defaultSet a width such as w-56; the default tracks the trigger.

MenubarItem

A command.

Other props spread onto Base UI Menu.Item.

Props of MenubarItem
PropTypeDefaultDescription
variant"default" | "destructive""default"Red text and tint for destructive commands.
insetbooleanNo defaultIndents the label 28px to line up with checkbox items.
disabledbooleanfalseDims and skips the item.
onClick(event) => voidNo defaultRuns the command.

MenubarCheckboxItem

A toggle with a check in the gutter.

Other props spread onto Base UI Menu.CheckboxItem.

Props of MenubarCheckboxItem
PropTypeDefaultDescription
checkedbooleanNo defaultControlled state.
defaultCheckedbooleanNo defaultUncontrolled initial state.
onCheckedChange(checked: boolean, eventDetails) => voidNo defaultCalled on toggle.
insetbooleanNo defaultKept for parity; checkbox items are always inset.

MenubarRadioGroup

Groups radio items into one choice.

Other props spread onto Base UI Menu.RadioGroup.

Props of MenubarRadioGroup
PropTypeDefaultDescription
valueanyNo defaultControlled value.
onValueChange(value, eventDetails) => voidNo defaultCalled when the choice changes.

MenubarRadioItem

One option, checked when it matches the group's value.

Other props spread onto Base UI Menu.RadioItem.

Props of MenubarRadioItem
PropTypeDefaultDescription
valueRequiredanyNo defaultThe option's value.
insetbooleanNo defaultKept for parity; radio items are always inset.

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.

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.