Menüleiste
Menüs in einer Leiste wie in einer Desktop-App. Ein Tab-Stopp, Pfeile laufen durch die Menüs und wechseln zwischen ihnen, Häkchen und Auswahl merken sich ihren Zustand.
Eine von 3 Components in Menüleisten und Umschalter
Variants
Details
Was Menüleiste mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Menüleisten und Umschalter
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/menubar.tsx
- Bringt mit
- utilskiticondropdown-menupopover
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 1 eigener Text
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MenubarProps aus components/ui/menubar.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMenubarVariantclassiciconsfloatingvertical
menusPflichtMenubarMenu[]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
Eingebaute Texte
Diese Texte schreibt Menüleiste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 1 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| menubar | Englisch Menu bar | Deutsch Menüleiste |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Menüleiste reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-label, aria-live und aria-orientation, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle menubar.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 274 Zeilen
'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>
)}
</>
);
}Häufige Fragen
Wie installiere ich Menüleiste?
Mit aiw add @ai-website/menubar. Der Befehl legt die Datei components/ui/menubar.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, dropdown-menu und popover. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Menüleiste?
4 Varianten, Klassisch, Icons und Beschriftung, Schwebende Leiste und Senkrechte Leiste. Du wählst sie mit der Prop variant, zum Beispiel variant="floating".
Kann ich die Texte von Menüleiste übersetzen?
Ja. Menüleiste bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Menüleiste“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Menüleiste einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Menüleiste mit aiw_components und installieren sie mit aiw_add @ai-website/menubar. Nenn im Prompt die Variante, etwa variant="icons", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Menüleiste ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Menubar-Code zum Kopieren.