Menubar
An application menu bar: a row of top-level menus — File, Edit, View — whose dropdowns are ordinary Menu content. Arrow keys move between the menus, and opening one while another is open switches to it.
Basic
A Menubar.Root holds one Menubar.Menu per top-level menu. Each Menubar.Menu pairs a Menubar.Trigger (the labelled button in the bar) with a Menu.Content holding the usual Menu rows — items, separators, shortcuts, checkboxes, and radio groups. Menubar ships no item parts of its own; it reuses Menu's, so a bar menu and a standalone dropdown look identical.
Triggers are bare words by platform convention (macOS, VS Code, Word) — no chevron — and rest at full label contrast, so the bar reads as first-class navigation chrome. Name the bar for assistive technology with aria-label (or aria-labelledby) on Menubar.Root.
"use client";
import { Menu, Menubar } from "@stridge/noctis";
export default function MenubarBasic() {
return (
// Name the bar for assistive tech with `aria-label` (or `aria-labelledby`). Triggers are bare
// words by default; each `Menu.Shortcut` floats to the inline-end so the hint column lines up.
<Menubar.Root aria-label="Application">
<Menubar.Menu>
<Menubar.Trigger>File</Menubar.Trigger>
<Menu.Content>
<Menu.Item>
New file
<Menu.Shortcut keys="Mod+N" />
</Menu.Item>
<Menu.Item>
Open…
<Menu.Shortcut keys="Mod+O" />
</Menu.Item>
<Menu.Separator />
<Menu.Item>
Save
<Menu.Shortcut keys="Mod+S" />
</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content>
<Menu.Item>
Undo
<Menu.Shortcut keys="Mod+Z" />
</Menu.Item>
<Menu.Item>
Redo
<Menu.Shortcut keys="Mod+Shift+Z" />
</Menu.Item>
<Menu.Separator />
<Menu.Item>Cut</Menu.Item>
<Menu.Item>Copy</Menu.Item>
<Menu.Item>Paste</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>View</Menubar.Trigger>
<Menu.Content>
<Menu.CheckboxItem defaultChecked>Show sidebar</Menu.CheckboxItem>
<Menu.CheckboxItem>Show status bar</Menu.CheckboxItem>
<Menu.Separator />
<Menu.RadioGroup defaultValue="comfortable">
<Menu.RadioItem value="comfortable">Comfortable</Menu.RadioItem>
<Menu.RadioItem value="compact">Compact</Menu.RadioItem>
</Menu.RadioGroup>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
);
}
In an app header
A menubar is app-shell chrome — it belongs in a title strip, not floating on the page. Compose Menubar.Root inside a header Surface that carries the chrome (border, fill); the bar itself stays transparent, with a brand mark on the start and a command hint or avatar on the end.
"use client";
import { Avatar, Kbd, Menu, Menubar, Surface } from "@stridge/noctis";
export default function MenubarInAppHeader() {
// A menubar is app-shell chrome — it belongs in a header strip, not floating on the page. The bar
// stays transparent; the surrounding `Surface` carries the chrome (border, fill). `w-full` keeps the
// strip spanning its container, with the brand on the start and a command hint + avatar on the end.
return (
<Surface bordered className="flex w-full items-center gap-2 rounded-md px-2 py-1.5">
<span className="px-1 text-small font-semibold tracking-tight">Noctis</span>
<Menubar.Root aria-label="Application">
<Menubar.Menu>
<Menubar.Trigger>File</Menubar.Trigger>
<Menu.Content>
<Menu.Item>
New file
<Menu.Shortcut keys="Mod+N" />
</Menu.Item>
<Menu.Item>
Open…
<Menu.Shortcut keys="Mod+O" />
</Menu.Item>
<Menu.Separator />
<Menu.Item>
Save
<Menu.Shortcut keys="Mod+S" />
</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content>
<Menu.Item>Undo</Menu.Item>
<Menu.Item>Redo</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>View</Menubar.Trigger>
<Menu.Content>
<Menu.CheckboxItem defaultChecked>Show sidebar</Menu.CheckboxItem>
<Menu.CheckboxItem>Show status bar</Menu.CheckboxItem>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
<div className="ms-auto flex items-center gap-3">
<Kbd keys="Mod+K" />
<Avatar.Root size="sm">
<Avatar.Fallback seed="Ada Lovelace">AL</Avatar.Fallback>
</Avatar.Root>
</div>
</Surface>
);
}
With icons and submenus
Give a Menubar.Trigger a leading icon for a brand / app menu, and nest a Menu.SubmenuRoot inside any menu for a submenu — exactly as in a standalone Menu. Triggers have no chevron by default; opt one in with chevron for a dropdown-button-style trigger. modal={false} leaves the page interactive while a menu is open. A disabled trigger or item is grayed and announced as unavailable, with the resting cursor — never a forbidden one.
"use client";
import { Menu, Menubar } from "@stridge/noctis";
import { FilePlus2, FolderOpen, Redo2, Save, Settings, Undo2 } from "lucide-react";
export default function MenubarWithIcons() {
return (
// `modal={false}` leaves the page interactive while a menu is open (no inert backdrop / scroll
// lock) — a lighter bar for app chrome. A leading `icon` suits a brand / app menu.
<Menubar.Root modal={false} aria-label="Application">
<Menubar.Menu>
<Menubar.Trigger icon={Settings}>File</Menubar.Trigger>
<Menu.Content>
<Menu.Item icon={FilePlus2}>New file</Menu.Item>
<Menu.Item icon={FolderOpen}>Open folder…</Menu.Item>
<Menu.Separator />
<Menu.Item icon={Save}>
Save
<Menu.Shortcut keys="Mod+S" />
</Menu.Item>
{/* A disabled item is grayed and announced, but stays in the AX tree. */}
<Menu.Item icon={Save} disabled>
Save as…
<Menu.Shortcut keys="Mod+Shift+S" />
</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content>
<Menu.Item icon={Undo2}>Undo</Menu.Item>
<Menu.Item icon={Redo2}>Redo</Menu.Item>
<Menu.Separator />
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger inset>Find</Menu.SubmenuTrigger>
<Menu.Content>
<Menu.Item>Find in file…</Menu.Item>
<Menu.Item>Find in project…</Menu.Item>
<Menu.Item>Replace…</Menu.Item>
</Menu.Content>
</Menu.SubmenuRoot>
</Menu.Content>
</Menubar.Menu>
{/* A disabled trigger reads as unavailable (grayed, no forbidden cursor) and can't open. */}
<Menubar.Menu>
<Menubar.Trigger disabled>Selection</Menubar.Trigger>
<Menu.Content>
<Menu.Item>Select all</Menu.Item>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
);
}
Button triggers
For a dropdown-button toolbar, render a trigger as a Noctis Button with Base UI's render prop — render={<Button variant="outline" size="sm" />}. The menubar's own ghost paint steps aside for any trigger carrying data-button, so the Button styles cleanly while the roving-focus and switch-on-hover behaviour stay intact. Pair it with chevron so each reads as a dropdown button.
"use client";
import { Button, Menu, Menubar } from "@stridge/noctis";
export default function MenubarButtonTrigger() {
// Render a trigger as a Noctis `Button` for a dropdown-button toolbar (the case the `chevron` prop
// is kept for). Base UI's `render` swaps the element; the menubar's own ghost paint steps aside for
// anything carrying `data-button`, so the Button styles cleanly while the menu behaviour is intact.
return (
<Menubar.Root aria-label="Toolbar">
<Menubar.Menu>
<Menubar.Trigger chevron render={<Button variant="outline" size="sm" />}>
File
</Menubar.Trigger>
<Menu.Content>
<Menu.Item>
New file
<Menu.Shortcut keys="Mod+N" />
</Menu.Item>
<Menu.Item>Open…</Menu.Item>
<Menu.Separator />
<Menu.Item>Save</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger chevron render={<Button variant="outline" size="sm" />}>
Edit
</Menubar.Trigger>
<Menu.Content>
<Menu.Item>Undo</Menu.Item>
<Menu.Item>Redo</Menu.Item>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
);
}
Sizes
Menubar.Root takes a size (sm | md | lg, default md) that re-points the trigger height, padding, and label size across the bar. Reach for lg in a spacious application header.
"use client";
import { Menu, Menubar } from "@stridge/noctis";
function Bar({ size }: { size: "sm" | "md" | "lg" }) {
return (
<Menubar.Root size={size}>
<Menubar.Menu>
<Menubar.Trigger>File</Menubar.Trigger>
<Menu.Content>
<Menu.Item>New file</Menu.Item>
<Menu.Item>Open…</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content>
<Menu.Item>Undo</Menu.Item>
<Menu.Item>Redo</Menu.Item>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
);
}
export default function MenubarSizes() {
return (
<div className="flex flex-col gap-4">
<Bar size="sm" />
<Bar size="md" />
<Bar size="lg" />
</div>
);
}
Controlled open menu
Base UI tracks the open menu per Menubar.Menu, so the controlled-open seam lives on the menu (open / onOpenChange), not on the bar. Drive it from host state to open a menu for a guided tour, a deep link, or analytics.
"use client";
import { Button, Menu, Menubar } from "@stridge/noctis";
import { useState } from "react";
export default function MenubarControlled() {
// Base UI tracks the open menu per `Menubar.Menu`, so the controlled-open seam lives on the menu
// (`open` / `onOpenChange`), not on the bar — drive it from host state for tours, deep links, or
// analytics. Here an outside button opens the File menu.
const [fileOpen, setFileOpen] = useState(false);
return (
<div className="flex flex-col items-start gap-3">
<Button variant="secondary" size="sm" onClick={() => setFileOpen((open) => !open)}>
{fileOpen ? "Close" : "Open"} File menu
</Button>
<Menubar.Root aria-label="Application">
<Menubar.Menu open={fileOpen} onOpenChange={setFileOpen}>
<Menubar.Trigger>File</Menubar.Trigger>
<Menu.Content>
<Menu.Item>New file</Menu.Item>
<Menu.Item>Open…</Menu.Item>
<Menu.Separator />
<Menu.Item>Save</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content>
<Menu.Item>Undo</Menu.Item>
<Menu.Item>Redo</Menu.Item>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
</div>
);
}
Overflow
A real menu bar lives in a constrained strip. When the menus outgrow the width, collapse the spillover into a trailing Menubar.Overflow — an icon-only trigger that holds the menus that don't fit (the VS Code model). Measure which menus fit and render the rest as submenus inside the overflow menu; the reflow is instant and reduced-motion-safe. Menubar.Overflow defaults to a … glyph and a localized "More menus" label.
"use client";
import { Menu, Menubar } from "@stridge/noctis";
import { useEffect, useRef, useState } from "react";
const MENUS = [
{ id: "file", label: "File", items: ["New file", "Open…", "Save"] },
{ id: "edit", label: "Edit", items: ["Undo", "Redo", "Cut", "Copy", "Paste"] },
{ id: "view", label: "View", items: ["Zoom in", "Zoom out", "Reset zoom"] },
{ id: "go", label: "Go", items: ["Back", "Forward", "Go to file…"] },
{ id: "run", label: "Run", items: ["Start debugging", "Run without debugging"] },
{ id: "help", label: "Help", items: ["Documentation", "Release notes", "About"] },
];
// Reserve room for the square overflow trigger plus the bar's edge padding, so the fit calc never
// collapses a menu the overflow button would have covered anyway.
const OVERFLOW_RESERVE = 52;
export default function MenubarOverflow() {
const containerRef = useRef<HTMLDivElement>(null);
const naturalWidths = useRef<number[] | null>(null);
const [visible, setVisible] = useState(MENUS.length);
// Keep as many menus inline as fit; push the remainder into the trailing overflow menu — the VS
// Code spillover model. We snapshot each trigger's natural width once (all visible on first paint),
// then recompute the cut-off on resize. The reflow is instant (no transition), reduced-motion-safe.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const fit = () => {
if (!naturalWidths.current) {
const triggers = container.querySelectorAll('[data-slot="noctis-menubar-trigger"]:not([data-overflow])');
if (triggers.length < MENUS.length) return; // wait until the full row has painted
naturalWidths.current = Array.from(triggers, (el) => el.getBoundingClientRect().width);
}
const widths = naturalWidths.current;
const available = container.clientWidth;
const gap = 4;
const full = widths.reduce((sum, w) => sum + w + gap, 0);
if (full <= available) {
setVisible(MENUS.length);
return;
}
let used = OVERFLOW_RESERVE;
let count = 0;
for (const w of widths) {
used += w + gap;
if (used > available) break;
count += 1;
}
setVisible(Math.max(1, count));
};
const observer = new ResizeObserver(fit);
observer.observe(container);
return () => observer.disconnect();
}, []);
const inline = MENUS.slice(0, visible);
const spilled = MENUS.slice(visible);
return (
// Drag the bottom-right corner to narrow the strip and watch menus fold into the `…` overflow.
<div ref={containerRef} className="w-full max-w-xl min-w-40 resize-x overflow-hidden rounded-md border border-border p-1">
<Menubar.Root aria-label="Application">
{inline.map((menu) => (
<Menubar.Menu key={menu.id}>
<Menubar.Trigger>{menu.label}</Menubar.Trigger>
<Menu.Content>
{menu.items.map((item) => (
<Menu.Item key={item}>{item}</Menu.Item>
))}
</Menu.Content>
</Menubar.Menu>
))}
{spilled.length > 0 ? (
<Menubar.Menu>
<Menubar.Overflow />
<Menu.Content align="end">
{spilled.map((menu) => (
<Menu.SubmenuRoot key={menu.id}>
<Menu.SubmenuTrigger>{menu.label}</Menu.SubmenuTrigger>
<Menu.Content>
{menu.items.map((item) => (
<Menu.Item key={item}>{item}</Menu.Item>
))}
</Menu.Content>
</Menu.SubmenuRoot>
))}
</Menu.Content>
</Menubar.Menu>
) : null}
</Menubar.Root>
</div>
);
}
Vertical
Set orientation="vertical" to stack the triggers into a left-aligned rail; Up / Down arrow keys traverse the bar and the menus open to the inline-end. Give the rail a width so the full-bleed triggers have a column to lead their labels from.
"use client";
import { Menu, Menubar } from "@stridge/noctis";
export default function MenubarVertical() {
// A vertical bar stacks its triggers into a left-aligned rail — Up/Down arrow keys traverse it
// (Base UI mirrors the bar's keyboard model to the orientation). Give the rail a width so the
// full-bleed triggers have a column to lead their labels from.
return (
<Menubar.Root orientation="vertical" aria-label="Workspace" className="w-44">
<Menubar.Menu>
<Menubar.Trigger>File</Menubar.Trigger>
<Menu.Content side="inline-end" align="start">
<Menu.Item>New file</Menu.Item>
<Menu.Item>Open…</Menu.Item>
<Menu.Separator />
<Menu.Item>Save</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menu.Content side="inline-end" align="start">
<Menu.Item>Undo</Menu.Item>
<Menu.Item>Redo</Menu.Item>
</Menu.Content>
</Menubar.Menu>
<Menubar.Menu>
<Menubar.Trigger>View</Menubar.Trigger>
<Menu.Content side="inline-end" align="start">
<Menu.CheckboxItem defaultChecked>Show sidebar</Menu.CheckboxItem>
<Menu.CheckboxItem>Show status bar</Menu.CheckboxItem>
</Menu.Content>
</Menubar.Menu>
</Menubar.Root>
);
}
Best practices
A menubar is app-shell chrome for the title-bar File / Edit / View case — not the primary action surface. For primary actions prefer a command menu (the ⌘K pattern, as Linear does); it stays searchable and keyboard-driven. Keep the bar tight and quiet, and cap each menu near ~10 items (Geist) — reach for submenus or a command menu beyond that.
Keyboard
| Key | Action |
|---|---|
| → / ← | Move the roving focus to the next / previous top-level trigger (mirrored under RTL). |
| Home / End | First / last top-level trigger. |
| Enter / Space / ↓ | On a trigger: open its menu and highlight the first item. |
| ↑ | On a trigger: open its menu and highlight the last item. |
| → / ← | With a menu open, move to and open the adjacent top-level menu (mirrored under RTL). |
| ↓ / ↑ | Move the highlight within the open menu. |
| Esc | Close the open menu and return focus to its trigger. |
| Characters | Typeahead — jump to the menu row whose label matches what you type. |
While one menu is open, hovering a sibling trigger switches to its menu. A disabled trigger is announced as disabled and cannot be activated; it is skipped in arrow traversal (Base UI renders it as a native-disabled control), following the ARIA menu pattern.
Anatomy
Compose a menu bar from Menubar for the bar and triggers, and Menu for each dropdown.
Menubar.Root— therole="menubar"bar; a roving-tabindex row of triggers. Label it witharia-label/aria-labelledby. Modal by default (page scroll locked while a menu is open, outside content inert); passmodal={false}for a lighter bar. Takessize(sm|md|lg),orientation(horizontal|vertical),loopFocus, anddisabled(disables the whole bar).Menubar.Menu— owns one top-level menu's open state; renders no element of its own. It is aMenu.Root, so it accepts every Base UIMenu.Rootprop, includingopen/defaultOpen/onOpenChangefor the controlled open menu.Menubar.Trigger— the labelled button in the bar that opens its menu. Optional leadingicon; a trailingchevron(default off).Menubar.Overflow— an icon-only trailing trigger for the spillover menus; shares the trigger slot, defaults to a…glyph and a localized "More menus"aria-label.Menu.Contentand theMenurows — the dropdown is ordinaryMenucontent:Menu.Item,Menu.CheckboxItem,Menu.RadioGroup/RadioItem,Menu.Groupwith aGroupLabel,Menu.Separator,Menu.Shortcut, and submenus viaMenu.SubmenuRoot. See the Menu page for those parts.
Menubar's own parts carry a data-slot (menubar, menubar-trigger) for host-side styling — pair it with the state attributes (data-orientation, data-size, data-has-submenu-open on the bar; data-popup-open, data-highlighted, data-disabled, data-overflow on a trigger). The dropdown parts carry the Menu slots.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every menu bar in that region retunes — e.g. .toolbar { --noctis-menubar-trigger-padding-inline: 0.5rem; } tightens every trigger beneath it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.