# Menubar

> A desktop-style application menubar with submenus, checkbox and radio items, shortcuts and full keyboard support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/menubar
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/menubar.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/menubar-demo.tsx"
"use client"

import * as React from "react"

import { Menubar, type MenubarMenu } from "@/components/menubar"

export default function MenubarDemo() {
  const [last, setLast] = React.useState("Nothing yet")
  const [bookmarks, setBookmarks] = React.useState(true)
  const [fullUrls, setFullUrls] = React.useState(false)
  const [density, setDensity] = React.useState("comfortable")

  const run = (name: string) => () => setLast(name)

  const menus: MenubarMenu[] = [
    {
      label: "File",
      items: [
        { label: "New tab", shortcut: "⌘T", onSelect: run("New tab") },
        { label: "New window", shortcut: "⌘N", onSelect: run("New window") },
        { label: "New incognito window", disabled: true },
        { type: "separator" },
        {
          type: "submenu",
          label: "Share",
          items: [
            { label: "Email link", onSelect: run("Share > Email link") },
            { label: "Messages", onSelect: run("Share > Messages") },
            { label: "Notes", onSelect: run("Share > Notes") },
          ],
        },
        { type: "separator" },
        { label: "Print…", shortcut: "⌘P", onSelect: run("Print") },
      ],
    },
    {
      label: "Edit",
      items: [
        { label: "Undo", shortcut: "⌘Z", onSelect: run("Undo") },
        { label: "Redo", shortcut: "⇧⌘Z", onSelect: run("Redo") },
        { type: "separator" },
        {
          type: "submenu",
          label: "Find",
          items: [
            { label: "Search the web", onSelect: run("Find > Search the web") },
            { label: "Find…", shortcut: "⌘F", onSelect: run("Find > Find") },
            { label: "Find next", shortcut: "⌘G", onSelect: run("Find > Find next") },
            { label: "Find previous", shortcut: "⇧⌘G", onSelect: run("Find > Find previous") },
          ],
        },
        { type: "separator" },
        { label: "Cut", shortcut: "⌘X", onSelect: run("Cut") },
        { label: "Copy", shortcut: "⌘C", onSelect: run("Copy") },
        { label: "Paste", shortcut: "⌘V", onSelect: run("Paste") },
      ],
    },
    {
      label: "View",
      items: [
        { type: "checkbox", label: "Always show bookmarks", checked: bookmarks, onCheckedChange: setBookmarks },
        { type: "checkbox", label: "Always show full URLs", checked: fullUrls, onCheckedChange: setFullUrls },
        { type: "separator" },
        { label: "Reload", shortcut: "⌘R", onSelect: run("Reload") },
        { label: "Force reload", shortcut: "⇧⌘R", disabled: true },
        { type: "separator" },
        {
          type: "radio-group",
          label: "Density",
          value: density,
          onValueChange: setDensity,
          options: [
            { value: "compact", label: "Compact" },
            { value: "comfortable", label: "Comfortable" },
            { value: "spacious", label: "Spacious" },
          ],
        },
      ],
    },
    {
      label: "Help",
      items: [
        { label: "Documentation", onSelect: run("Documentation") },
        { label: "Keyboard shortcuts", shortcut: "⌘K", onSelect: run("Keyboard shortcuts") },
        { type: "separator" },
        { label: "Report an issue", onSelect: run("Report an issue") },
      ],
    },
  ]

  return (
    <div className="flex h-[26rem] w-full max-w-xl flex-col gap-4">
      <Menubar menus={menus} defaultOpenIndex={1} />
      <p className="mt-auto text-center text-xs text-muted-foreground">
        Last action: <span className="font-medium text-foreground">{last}</span> · bookmarks {bookmarks ? "on" : "off"} · full URLs{" "}
        {fullUrls ? "on" : "off"} · {density}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/menubar.tsx"
"use client"

import * as React from "react"
import { Check, ChevronRight, Circle } from "lucide-react"

import { cn } from "@/lib/utils"

export type MenubarEntry =
  | { type?: "item"; label: string; shortcut?: string; disabled?: boolean; destructive?: boolean; onSelect?: () => void }
  | { type: "checkbox"; label: string; shortcut?: string; checked: boolean; disabled?: boolean; onCheckedChange: (checked: boolean) => void }
  | {
      type: "radio-group"
      label?: string
      value: string
      onValueChange: (value: string) => void
      options: { value: string; label: string; disabled?: boolean }[]
    }
  | { type: "label"; label: string }
  | { type: "separator" }
  | { type: "submenu"; label: string; disabled?: boolean; items: MenubarEntry[] }

export interface MenubarMenu {
  label: string
  items: MenubarEntry[]
}

export interface MenubarProps extends Omit<React.ComponentProps<"div">, "children"> {
  menus: MenubarMenu[]
  /** Render the menu at this index open on mount (without moving focus) */
  defaultOpenIndex?: number
}

type FocusTarget = "first" | "last" | "none"
type SubSide = "right" | "left" | "below"

const PANEL_WIDTH = 240
const SUB_WIDTH = 208
const EDGE = 12

const itemClass =
  "flex h-8 w-full cursor-default items-center gap-2 rounded-md px-2 text-left text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50"

function getItems(root: HTMLElement | null) {
  if (!root) return []
  return Array.from(root.querySelectorAll<HTMLElement>("[data-mb-item]")).filter(
    (el) => el.closest('[role="menu"]') === root && el.getAttribute("aria-disabled") !== "true"
  )
}

interface MenuPanelProps {
  id: string
  entries: MenubarEntry[]
  labelledBy: string
  focus: FocusTarget
  nested?: boolean
  className?: string
  /** Close the whole menubar; optionally return focus to the active trigger */
  onClose: (restoreFocus: boolean) => void
  /** Move to the previous/next top-level menu */
  onNavigate: (direction: 1 | -1) => void
  /** Nested panels only: close this submenu and refocus its parent item */
  onExit?: () => void
}

function MenuPanel({ id, entries, labelledBy, focus, nested, className, onClose, onNavigate, onExit }: MenuPanelProps) {
  const ref = React.useRef<HTMLDivElement>(null)
  const typed = React.useRef({ text: "", at: 0 })
  const [sub, setSub] = React.useState<{ index: number; focus: FocusTarget; side: SubSide } | null>(null)

  React.useEffect(() => {
    if (focus === "none") return
    const all = getItems(ref.current)
    const target = focus === "first" ? all[0] : all[all.length - 1]
    target?.focus()
  }, [focus])

  const openSub = (index: number, el: HTMLElement, target: FocusTarget) => {
    const rect = el.getBoundingClientRect()
    const side: SubSide =
      rect.right + SUB_WIDTH + EDGE <= window.innerWidth ? "right" : rect.left - SUB_WIDTH - EDGE >= 0 ? "left" : "below"
    setSub({ index, focus: target, side })
  }

  const hover = (el: HTMLElement) => {
    if (el.getAttribute("aria-disabled") === "true") return
    if (document.activeElement !== el) el.focus({ preventScroll: true })
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    // Nested panels sit inside their parent's DOM, so keep keys from reaching the parent handler
    event.stopPropagation()
    const all = getItems(ref.current)
    const current = document.activeElement as HTMLElement | null
    const index = current ? all.indexOf(current) : -1
    const move = (to: number) => {
      if (all.length) all[(to + all.length) % all.length].focus()
    }
    switch (event.key) {
      case "ArrowDown":
        move(index + 1)
        break
      case "ArrowUp":
        move(index < 0 ? -1 : index - 1)
        break
      case "Home":
        move(0)
        break
      case "End":
        move(-1)
        break
      case "ArrowRight":
        if (current?.hasAttribute("data-mb-sub")) current.click()
        else onNavigate(1)
        break
      case "ArrowLeft":
        if (nested && onExit) onExit()
        else onNavigate(-1)
        break
      case "Escape":
        if (nested && onExit) onExit()
        else onClose(true)
        break
      case "Tab":
        onClose(true)
        return
      default: {
        if (event.key.length !== 1 || !/\S/.test(event.key) || event.metaKey || event.ctrlKey || event.altKey) return
        // Typeahead: jump to the next item starting with the typed letters
        const now = event.timeStamp
        typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
        all.find((el) => el.textContent?.trim().toLowerCase().startsWith(typed.current.text))?.focus()
        return
      }
    }
    event.preventDefault()
  }

  const select = (action?: () => void) => {
    action?.()
    onClose(true)
  }

  return (
    <div
      ref={ref}
      id={id}
      role="menu"
      aria-labelledby={labelledBy}
      onKeyDown={onKeyDown}
      className={cn(
        "absolute z-50 max-w-[calc(100vw-2rem)] rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 motion-reduce:animate-none",
        className
      )}
    >
      {entries.map((entry, i) => {
        const key = `${i}-${"label" in entry ? entry.label : (entry.type ?? "")}`
        switch (entry.type) {
          case "separator":
            return <div key={key} role="separator" className="-mx-1 my-1 h-px bg-border" />
          case "label":
            return (
              <div key={key} role="presentation" className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
                {entry.label}
              </div>
            )
          case "checkbox":
            return (
              <button
                key={key}
                type="button"
                role="menuitemcheckbox"
                aria-checked={entry.checked}
                aria-disabled={entry.disabled || undefined}
                data-mb-item
                tabIndex={-1}
                onPointerMove={(event) => {
                  hover(event.currentTarget)
                  setSub(null)
                }}
                onClick={() => !entry.disabled && select(() => entry.onCheckedChange(!entry.checked))}
                className={itemClass}
              >
                <span className="flex size-4 shrink-0 items-center justify-center">
                  {entry.checked && <Check className="size-4" />}
                </span>
                <span className="min-w-0 flex-1 truncate">{entry.label}</span>
                {entry.shortcut && <span className="text-xs tracking-widest text-muted-foreground">{entry.shortcut}</span>}
              </button>
            )
          case "radio-group":
            return (
              <div key={key} role="group" aria-label={entry.label}>
                {entry.label && (
                  <div role="presentation" className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
                    {entry.label}
                  </div>
                )}
                {entry.options.map((option) => {
                  const checked = option.value === entry.value
                  return (
                    <button
                      key={option.value}
                      type="button"
                      role="menuitemradio"
                      aria-checked={checked}
                      aria-disabled={option.disabled || undefined}
                      data-mb-item
                      tabIndex={-1}
                      onPointerMove={(event) => {
                        hover(event.currentTarget)
                        setSub(null)
                      }}
                      onClick={() => !option.disabled && select(() => entry.onValueChange(option.value))}
                      className={itemClass}
                    >
                      <span className="flex size-4 shrink-0 items-center justify-center">
                        {checked && <Circle className="size-2 fill-current" />}
                      </span>
                      <span className="min-w-0 flex-1 truncate">{option.label}</span>
                    </button>
                  )
                })}
              </div>
            )
          case "submenu": {
            const isOpen = sub?.index === i
            return (
              <div key={key} role="none" className="relative">
                <button
                  type="button"
                  role="menuitem"
                  id={`${id}-item-${i}`}
                  aria-haspopup="menu"
                  aria-expanded={isOpen}
                  aria-controls={isOpen ? `${id}-sub-${i}` : undefined}
                  aria-disabled={entry.disabled || undefined}
                  data-mb-item
                  data-mb-sub
                  tabIndex={-1}
                  onPointerMove={(event) => {
                    if (entry.disabled) return setSub(null)
                    hover(event.currentTarget)
                    if (!isOpen) openSub(i, event.currentTarget, "none")
                  }}
                  onClick={(event) => !entry.disabled && openSub(i, event.currentTarget, event.detail === 0 ? "first" : "none")}
                  className={cn(itemClass, isOpen && "bg-accent")}
                >
                  <span className="min-w-0 flex-1 truncate">{entry.label}</span>
                  <ChevronRight className="size-4 opacity-60" />
                </button>
                {isOpen && sub && (
                  <MenuPanel
                    id={`${id}-sub-${i}`}
                    entries={entry.items}
                    labelledBy={`${id}-item-${i}`}
                    focus={sub.focus}
                    nested
                    className={cn(
                      sub.side === "right" && "-top-1 left-full ml-1.5 w-52 origin-top-left",
                      sub.side === "left" && "-top-1 right-full mr-1.5 w-52 origin-top-right",
                      sub.side === "below" && "top-full right-2 left-2 mt-1 origin-top"
                    )}
                    onClose={onClose}
                    onNavigate={onNavigate}
                    onExit={() => {
                      setSub(null)
                      document.getElementById(`${id}-item-${i}`)?.focus()
                    }}
                  />
                )}
              </div>
            )
          }
          default:
            return (
              <button
                key={key}
                type="button"
                role="menuitem"
                aria-disabled={entry.disabled || undefined}
                data-mb-item
                tabIndex={-1}
                onPointerMove={(event) => {
                  hover(event.currentTarget)
                  setSub(null)
                }}
                onClick={() => !entry.disabled && select(entry.onSelect)}
                className={cn(itemClass, entry.destructive && "text-destructive focus:bg-destructive/10 focus:text-destructive")}
              >
                <span className="size-4 shrink-0" aria-hidden />
                <span className="min-w-0 flex-1 truncate">{entry.label}</span>
                {entry.shortcut && <span className="text-xs tracking-widest text-muted-foreground">{entry.shortcut}</span>}
              </button>
            )
        }
      })}
    </div>
  )
}

export function Menubar({ menus, defaultOpenIndex, className, ...props }: MenubarProps) {
  const baseId = React.useId()
  const root = React.useRef<HTMLDivElement>(null)
  const triggers = React.useRef<(HTMLButtonElement | null)[]>([])
  const [open, setOpen] = React.useState<{ index: number; focus: FocusTarget; align: "start" | "end" } | null>(
    defaultOpenIndex === undefined ? null : { index: defaultOpenIndex, focus: "none", align: "start" }
  )
  const [tabbable, setTabbable] = React.useState(defaultOpenIndex ?? 0)
  const count = menus.length

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(null)
    }
    // Escape from inside is handled by the menu; this covers an open menu that never received focus
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen(null)
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [open])

  const openMenu = (index: number, focus: FocusTarget) => {
    const trigger = triggers.current[index]
    const align = trigger && trigger.getBoundingClientRect().left + PANEL_WIDTH + EDGE > window.innerWidth ? "end" : "start"
    setOpen({ index, focus, align })
    setTabbable(index)
  }

  const close = (restoreFocus: boolean) => {
    if (restoreFocus && open) triggers.current[open.index]?.focus()
    setOpen(null)
  }

  const navigate = (from: number, direction: 1 | -1) => {
    openMenu((from + direction + count) % count, "first")
  }

  const onTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
    const focusTrigger = (to: number) => {
      const next = (to + count) % count
      triggers.current[next]?.focus()
      if (open) openMenu(next, "first")
    }
    switch (event.key) {
      case "ArrowRight":
        focusTrigger(index + 1)
        break
      case "ArrowLeft":
        focusTrigger(index - 1)
        break
      case "Home":
        focusTrigger(0)
        break
      case "End":
        focusTrigger(count - 1)
        break
      case "ArrowDown":
        openMenu(index, "first")
        break
      case "ArrowUp":
        openMenu(index, "last")
        break
      case "Escape":
        if (!open) return
        setOpen(null)
        break
      default:
        return
    }
    event.preventDefault()
  }

  return (
    <div
      ref={root}
      role="menubar"
      aria-label="Application menu"
      className={cn("flex w-fit max-w-full items-center gap-1 rounded-xl border bg-background p-1 shadow-xs", className)}
      {...props}
    >
      {menus.map((menu, index) => {
        const isOpen = open?.index === index
        const triggerId = `${baseId}-trigger-${index}`
        const panelId = `${baseId}-menu-${index}`
        return (
          <div key={menu.label} role="none" className="relative">
            <button
              ref={(el) => {
                triggers.current[index] = el
              }}
              id={triggerId}
              type="button"
              role="menuitem"
              aria-haspopup="menu"
              aria-expanded={isOpen}
              aria-controls={isOpen ? panelId : undefined}
              tabIndex={tabbable === index ? 0 : -1}
              onFocus={() => setTabbable(index)}
              onPointerEnter={(event) => {
                // Once a menu is open, hovering another top-level item switches to it
                if (event.pointerType === "mouse" && open && open.index !== index) {
                  event.currentTarget.focus()
                  openMenu(index, "none")
                }
              }}
              onClick={() => (isOpen ? setOpen(null) : openMenu(index, "first"))}
              onKeyDown={(event) => onTriggerKeyDown(event, index)}
              className="h-8 rounded-lg px-3 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 aria-expanded:bg-accent"
            >
              {menu.label}
            </button>
            {isOpen && open && (
              <MenuPanel
                id={panelId}
                entries={menu.items}
                labelledBy={triggerId}
                focus={open.focus}
                className={cn("top-full mt-1.5 w-60", open.align === "end" ? "right-0 origin-top-right" : "left-0 origin-top-left")}
                onClose={close}
                onNavigate={(direction) => navigate(index, direction)}
              />
            )}
          </div>
        )
      })}
    </div>
  )
}
```
