Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Details

Was Menüleiste mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle MenubarProps aus components/ui/menubar.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MenubarVariant

    classiciconsfloatingvertical

  • menusPflicht
    MenubarMenu[]
  • labeloptional
    string

    Accessible name of the bar

  • onActionoptional
    (menu: string, item: string) => void

    Called with the menu id and the item label when an item is chosen

  • defaultOpenoptional
    string

    Open 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
menubarEnglisch Menu barDeutsch 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
components/ui/menubar.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert