Skip to content
UI Kit
Accent color

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

4 variants

Variants

Details

What Menubar brings along, read from the registry and the source.

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

Props

The MenubarProps interface from components/ui/menubar.tsx, with the comments from the code.

  • variantoptional
    MenubarVariant

    classiciconsfloatingvertical

  • menusrequired
    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

Built-in texts

Menubar writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 1 texts
menubarEnglish Menu barGerman 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
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>
      )}
    </>
  );
}

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.

Often searched

Jump to

Copied to the clipboard