Menubar
Menus in a bar as in a desktop app. One tab stop, arrows walk the menus and step between them, checkbox and radio items keep their state.
One of 3 components in Menu bars and switchers
Variants
Details
What Menubar brings along, read from the registry and the source.
- Category
- Menu bars and switchers
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/menubar.tsx
- Brings along
- utilskiticondropdown-menupopover
- npm packages
- None
- Languages
- English and German, 1 text of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MenubarProps interface from components/ui/menubar.tsx, with the comments from the code.
variantoptionalMenubarVariantclassiciconsfloatingvertical
menusrequiredMenubarMenu[]labeloptionalstringAccessible name of the bar
onActionoptional(menu: string, item: string) => voidCalled with the menu id and the item label when an item is chosen
defaultOpenoptionalstringOpen this menu on first render, for demos
Built-in texts
Menubar writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 1 texts
| Key | English | German |
|---|---|---|
| menubar | English Menu bar | German Menüleiste |
Accessibility
Read from the source of the component.
- Menubar handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-label, aria-live and aria-orientation, so screen readers announce purpose and state.
- Uses the role menubar.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 274 lines
'use client';
import { useCallback, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { MenuList, type MenuItem } from '@/components/ui/dropdown-menu';
import { Floating, floatingPop, originFor, useDismiss, useMounted, usePresence, type Side } from '@/components/ui/popover';
/*
* A row (or column) of menus, as in a desktop app, after the WAI-ARIA APG
* menubar pattern: one tab stop, the arrows walk the menus, Down, Enter or
* Space opens one, inside a menu Left and Right step to the next menu, and
* Escape goes back to the bar. While one menu is open, pointing at another
* opens that one. Checkbox and radio items keep their own state. Four looks:
* a classic bar under a document title, menus with icons above labels, a
* floating bar on a canvas whose menus open upward, and a vertical rail.
*/
const TEXTS = {
en: { menubar: 'Menu bar' },
de: { menubar: 'Menüleiste' },
};
export type MenubarItem =
| { type?: 'item'; label: string; icon?: IconName; shortcut?: string; description?: string; href?: string; disabled?: boolean; destructive?: boolean }
| { type: 'checkbox'; label: string; icon?: IconName; checked?: boolean; disabled?: boolean }
| { type: 'radio'; label: string; group: string; checked?: boolean; disabled?: boolean }
| { type: 'label'; label: string }
| { type: 'separator' };
export interface MenubarMenu {
id: string;
label: string;
icon?: IconName;
items: MenubarItem[];
}
export type MenubarVariant = 'classic' | 'icons' | 'floating' | 'vertical';
export interface MenubarProps {
variant?: MenubarVariant;
menus: MenubarMenu[];
/** Accessible name of the bar */
label?: string;
/** Called with the menu id and the item label when an item is chosen */
onAction?: (menu: string, item: string) => void;
/** Open this menu on first render, for demos */
defaultOpen?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const SIDE: Record<MenubarVariant, Side> = { classic: 'bottom', icons: 'bottom', floating: 'top', vertical: 'right' };
/**
* Menus in a bar, with the keyboard of a desktop app.
* <Menubar label="Document" menus={[{ id: 'file', label: 'File', items: [{ label: 'New', shortcut: '⌘N' }] }]} />
* <Menubar variant="floating" menus={…} />
* <Menubar variant="vertical" menus={…} />
*/
export function Menubar({ variant = 'classic', menus, label, onAction, defaultOpen, labels, className }: MenubarProps) {
const t = useTexts(TEXTS, labels);
const base = useId();
const mounted = useMounted();
const vertical = variant === 'vertical';
const [open, setOpen] = useState<string | null>(defaultOpen ?? null);
const [focusId, setFocusId] = useState(menus[0]?.id);
const [focusLast, setFocusLast] = useState(false);
// Checkbox and radio items remember their state here
const [checks, setChecks] = useState<Record<string, boolean>>(() =>
Object.fromEntries(menus.flatMap((m) => m.items.flatMap((it, i) => (it.type === 'checkbox' ? [[`${m.id}:${i}`, Boolean(it.checked)]] : [])))),
);
const [lastAction, setLastAction] = useState<string | null>(null);
const [radios, setRadios] = useState<Record<string, string>>(() =>
Object.fromEntries(menus.flatMap((m) => m.items.flatMap((it, i) => (it.type === 'radio' && it.checked ? [[`${m.id}:${it.group}`, String(i)]] : [])))),
);
// Radio choices are kept by position, so a renamed item stays chosen
const radioLabel = (key: string) => {
const [mid] = key.split(':');
const it = menus.find((m) => m.id === mid)?.items[Number(radios[key])];
return it && 'label' in it ? it.label : undefined;
};
const firstRadio = Object.keys(radios)[0] ? radioLabel(Object.keys(radios)[0]) : undefined;
const barRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const refs = useRef([barRef, panelRef]).current;
const openRef = useRef(open);
openRef.current = open;
// Escape goes back to the menu's own item in the bar, a click outside just closes
const close = useCallback((reason: 'escape' | 'outside') => {
const was = openRef.current;
setOpen(null);
if (reason === 'escape' && was) {
setFocusId(was);
requestAnimationFrame(() => document.getElementById(`${base}-${was}`)?.focus());
}
}, [base]);
useDismiss(Boolean(open), close, refs);
const { present, closing } = usePresence(Boolean(open));
const [shown, setShown] = useState<string | null>(open);
if (open && open !== shown) setShown(open);
const triggerId = (id: string) => `${base}-${id}`;
const focusTop = (id: string) => {
setFocusId(id);
requestAnimationFrame(() => document.getElementById(triggerId(id))?.focus());
};
const step = (id: string, d: number) => menus[(menus.findIndex((m) => m.id === id) + d + menus.length) % menus.length].id;
const openMenu = (id: string, last = false) => {
setFocusId(id);
setFocusLast(last);
setOpen(id);
};
const onTopKey = (e: React.KeyboardEvent, id: string) => {
const next = vertical ? 'ArrowDown' : 'ArrowRight';
const prev = vertical ? 'ArrowUp' : 'ArrowLeft';
const into = vertical ? 'ArrowRight' : 'ArrowDown';
if (e.key === next || e.key === prev) {
e.preventDefault();
const to = step(id, e.key === next ? 1 : -1);
if (open) openMenu(to);
else focusTop(to);
} else if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
focusTop(e.key === 'Home' ? menus[0].id : menus[menus.length - 1].id);
} else if (e.key === into || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openMenu(id);
} else if (!vertical && e.key === 'ArrowUp') {
e.preventDefault();
openMenu(id, true);
} else if (e.key.length === 1 && /\S/.test(e.key) && !e.metaKey && !e.ctrlKey) {
const hit = menus.find((m) => m.label.toLowerCase().startsWith(e.key.toLowerCase()));
if (hit) focusTop(hit.id);
}
};
// Left and Right inside an open menu step to the neighbouring menu
const onPanelKey = (e: React.KeyboardEvent) => {
if (!open) return;
if (vertical ? e.key === 'ArrowLeft' : e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
if (vertical) {
setOpen(null);
focusTop(open);
} else openMenu(step(open, e.key === 'ArrowRight' ? 1 : -1));
}
};
const toItems = (m: MenubarMenu): MenuItem[] =>
m.items.map((it, idx): MenuItem => {
if (it.type === 'separator' || it.type === 'label') return it;
if (it.type === 'checkbox') {
const key = `${m.id}:${idx}`;
return { type: 'checkbox', label: it.label, icon: it.icon, disabled: it.disabled, checked: Boolean(checks[key]), onCheckedChange: (v) => { setChecks((c) => ({ ...c, [key]: v })); onAction?.(m.id, it.label); } };
}
if (it.type === 'radio') {
const key = `${m.id}:${it.group}`;
return { type: 'radio', label: it.label, value: it.label, group: it.group, disabled: it.disabled, checked: radios[key] === String(idx), onSelect: () => { setRadios((r) => ({ ...r, [key]: String(idx) })); setLastAction(it.label); onAction?.(m.id, it.label); } };
}
return { ...it, type: 'item', onSelect: () => { setLastAction(it.label); onAction?.(m.id, it.label); } };
});
const current = menus.find((m) => m.id === (open ?? shown));
// A stable anchor for the panel, pointing at the trigger of the current menu
const anchorRef = useRef<HTMLElement | null>(null);
if (mounted && current) anchorRef.current = document.getElementById(triggerId(current.id));
/* ---------- the top items ---------- */
const top = (m: MenubarMenu) => {
const isOpen = open === m.id;
const common = {
id: triggerId(m.id),
role: 'menuitem',
'aria-haspopup': 'menu' as const,
'aria-expanded': isOpen,
'aria-controls': isOpen ? `${base}-menu` : undefined,
tabIndex: focusId === m.id ? 0 : -1,
onKeyDown: (e: React.KeyboardEvent) => onTopKey(e, m.id),
onClick: () => (isOpen ? setOpen(null) : openMenu(m.id)),
onPointerEnter: (e: React.PointerEvent) => e.pointerType === 'mouse' && open && open !== m.id && openMenu(m.id),
onFocus: () => setFocusId(m.id),
};
const state = isOpen ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground';
const ring = 'outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]';
if (variant === 'icons')
return (
<button key={m.id} type="button" {...common} className={cn('flex h-14 min-w-[4.25rem] shrink-0 flex-col items-center justify-center gap-1 rounded-lg px-2.5 text-[12px] font-medium transition-colors duration-150', state, ring)}>
{m.icon && <Icon name={m.icon} size={18} className={cn('transition-[translate] duration-200', isOpen && '-translate-y-px')} />}
{m.label}
</button>
);
if (variant === 'floating')
return (
<button key={m.id} type="button" {...common} aria-label={m.label} title={m.label} className={cn('grid size-11 shrink-0 place-items-center rounded-lg transition-[background-color,color,scale] duration-150 active:scale-95', state, ring)}>
{m.icon ? <Icon name={m.icon} size={19} /> : m.label}
</button>
);
if (variant === 'vertical')
return (
<button key={m.id} type="button" {...common} className={cn('flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[13.5px] font-medium transition-colors duration-150 max-sm:justify-center max-sm:px-0', state, ring)}>
{m.icon && <Icon name={m.icon} size={18} className="shrink-0" />}
<span className="truncate max-sm:sr-only">{m.label}</span>
<Icon name="chevron-right" size={14} className={cn('ms-auto text-subtle-foreground transition-[translate] duration-200 max-sm:hidden', isOpen && 'translate-x-0.5')} />
</button>
);
return (
<button key={m.id} type="button" {...common} className={cn('inline-flex h-11 shrink-0 items-center rounded-md px-3 text-[13.5px] transition-colors duration-150 sm:h-8 sm:px-2.5 sm:text-[13px]', state, ring)}>
{m.label}
</button>
);
};
const bar = (
<div
ref={barRef}
role="menubar"
aria-label={label ?? t.menubar}
aria-orientation={vertical ? 'vertical' : 'horizontal'}
className={cn(
variant === 'classic' && 'kit-no-scrollbar flex items-center gap-0.5 overflow-x-auto',
variant === 'icons' && 'kit-no-scrollbar flex items-center gap-1 overflow-x-auto',
variant === 'floating' && 'flex items-center gap-1',
variant === 'vertical' && 'flex flex-col gap-1',
variant !== 'floating' && className,
)}
>
{menus.map(top)}
</div>
);
return (
<>
{variant === 'floating' ? (
// The status sits beside the bar, not in it: a menubar owns only its menu items
<div className={cn('inline-flex items-center gap-1 rounded-xl border border-border bg-popover p-1.5 shadow-[var(--highlight),var(--shadow-lg)]', className)}>
{bar}
{/* The floating bar names what is active: the last choice, or the first chosen option */}
{(lastAction || firstRadio) && (
<span aria-live="polite" className="flex items-center gap-2 pe-2.5 ps-1 text-[12.5px] font-medium text-foreground max-[380px]:hidden">
<span aria-hidden className="h-5 w-px bg-border" />
<span key={lastAction ?? firstRadio} className="transition-[opacity,translate] duration-300 starting:translate-y-1 starting:opacity-0">{lastAction ?? firstRadio}</span>
</span>
)}
</div>
) : (
bar
)}
{mounted && present && current && (
<Floating key={current.id} anchor={anchorRef} side={SIDE[variant]} align={variant === 'floating' ? 'center' : 'start'} offset={variant === 'floating' ? 10 : 6} ref={panelRef}>
{(pos) => (
<div onKeyDownCapture={onPanelKey} className={cn(floatingPop)} data-closing={closing || undefined} style={{ '--origin': originFor(pos, SIDE[variant]) } as React.CSSProperties}>
<MenuList
key={current.id}
id={`${base}-menu`}
items={toItems(current)}
labelledBy={triggerId(current.id)}
autoFocus={focusLast ? 'last' : 'first'}
closing={closing}
onClose={(focusTrigger) => {
const was = open;
setOpen(null);
if (focusTrigger && was) focusTop(was);
}}
/>
</div>
)}
</Floating>
)}
</>
);
}Frequent questions
How do I install Menubar?
With aiw add @ai-website/menubar. The command puts components/ui/menubar.tsx into your project, the code is yours from then on. It brings along icon, dropdown-menu and popover. It needs no further npm packages.
Which variants does Menubar have?
4 variants, Classic, Icons and labels, Floating bar and Vertical rail. You pick one with the variant prop, for example variant="floating".
Can I translate the texts of Menubar?
Yes. Menubar carries 1 texts of its own in English and German, such as “Menu bar”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Menubar?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Menubar with aiw_components and install it with aiw_add @ai-website/menubar. Name the variant in your prompt, for example variant="icons", and the assistant writes your content straight into the props.
Can I adjust Menubar without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching Menubar code to copy.