Navigation

Menu Bar

A desktop application menubar — one click opens, hover then travels between menus, with submenus, shortcut hints, checkable rows and a full roving keyboard.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { Menubar as MenubarPrimitive } from "radix-ui"

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

/* -------------------------------------------------------------------------- *
 * Contract
 *
 * A desktop application menubar: one row of titles, one menu behind each, and
 * the whole thing described by data — the consumer hands over `menus` and never
 * touches a primitive.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/menu-bar.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MenuBar" component: a desktop application
menubar (File / Edit / View / Help) on top of the Radix `Menubar` primitives from
the `radix-ui` package, with lucide-react for the check, the radio dot and the
submenu chevron.

Contract
- forwardRef<HTMLDivElement> — the ref and every native prop that is not listed
  below land on the role="menubar" row, and className is merged onto it with cn().
- Props extend Omit<React.HTMLAttributes<HTMLDivElement>, "children" |
  "defaultValue" | "dir"> — children because the bar is described by data,
  defaultValue and dir because the primitive redefines both — and add:
  menus: MenuBarMenu[], label?: string (default "Application menu"),
  value?: string (controlled id of the open menu, "" is closed),
  onValueChange?: (value: string) => void, loop?: boolean (default true),
  dir?: "ltr" | "rtl", emptyLabel?: string (default "Nothing here yet").
- MenuBarMenu = { id, label, disabled?, items: MenuBarNode[] }.
- MenuBarNode is a discriminated union on `kind`:
  { kind: "item", id, label, shortcut?, icon?, disabled?, destructive?, onSelect? }
  | { kind: "checkbox", id, label, shortcut?, checked, onCheckedChange, disabled? }
  | { kind: "radio", id, label?, value, onValueChange,
      options: { value, label, shortcut?, disabled? }[] }
  | { kind: "submenu", id, label, icon?, disabled?, items: MenuBarNode[] }
  | { kind: "separator", id }
  | { kind: "label", id, label }.
- A `shortcut` is a hint, not a binding: it is printed right-aligned and read as
  part of the row's name, and the real key handler stays the consumer's. Same for
  onSelect — the bar performs nothing by itself.
- onValueChange reports the id the bar SETTLED on, which is not always the id it
  was asked for: an unavailable menu is skipped past or refused first.

Behavior
- Opening: the first click opens a menu; after that the bar owns the pointer and
  hovering another title switches to it with no second click. Clicking the open
  title closes it. That much is the primitive's; everything below is the layer
  the wrapper adds.
- Keyboard on the bar: ArrowLeft / ArrowRight move between titles (wrapping when
  loop), Home / End jump to the ends, Enter / Space / ArrowDown open the focused
  menu, Tab leaves the bar entirely — the whole row is ONE tab stop.
- Typeahead on the bar: the primitive has typeahead inside a menu and none across
  the titles, so add it. Printable keys accumulate into a buffer that expires
  1000ms after the last keystroke; a repeated single letter walks the matches
  (pressing "v" twice visits two V menus), a longer buffer is a prefix; ignore
  Space and any key with a modifier. Clear the timer on unmount. Keys that came
  from an open menu must be ignored, because portalled content bubbles through
  the REACT tree and arrives at the bar's handler as well — gate on
  event.currentTarget.contains(event.target), which is DOM containment and so is
  false for a portal.
- Keyboard inside a menu: ArrowDown / ArrowUp move, Home / End jump, typeahead
  matches rows, ArrowRight opens a submenu (and otherwise walks to the next menu),
  ArrowLeft closes a submenu (and otherwise walks to the previous one), Escape
  closes exactly one level and hands focus back to the title. Pass
  textValue={label} to every row so the shortcut hint sharing its text content
  cannot pollute the typeahead match.
- Unavailable ROWS use the primitive's `disabled`, which is aria-disabled on a
  div — never a native attribute. Do not add pointer-events-none: the refusal is
  already enforced in the handlers, and a row that swallows pointer events is a
  hole in the menu instead of something that can be hovered and inspected.
- An unavailable MENU cannot use that door, because a title is a real <button>
  and the native disabled attribute blurs it to <body> the instant a permission
  flips under someone standing on it. So never pass `disabled` to the trigger.
  Mark it aria-disabled and refuse in the handlers instead: preventDefault the
  pointerdown and then focus the title deliberately (a grey title you can land on
  is a title a screen reader can announce), preventDefault Enter / Space /
  ArrowDown, and preventDefault the pointerenter that would otherwise pull the
  open menu's focus onto it while hovering. The primitive composes handlers with
  a defaultPrevented check, so preventing the default is exactly what cancels its
  own behaviour.
- The arrow walk must not dead-end on that grey title, or every press would land
  on it again. Control the primitive's open value: when it asks for an
  unavailable menu, decide whether the request is the next or the previous
  neighbour of the currently open one — the only two shapes an arrow can produce
  — and keep hopping that way until an available menu turns up, honouring loop at
  the ends. Any other request (a click, a hover jump, a keypress while the bar is
  closed) is refused and the current menu stays where it is.
- Normalise the open value on every render:
  menus.some(m => m.id === value && !m.disabled) ? value : "". Without it a menu
  deleted or disabled while open leaves the bar toggling against a ghost — one
  click to clear the stale id, a second to finally open something.
- One activation per opening. Holding Enter auto-repeats into the highlighted
  row's click handler long before the close has rendered, so guard the consumer
  callbacks (onSelect, onCheckedChange, the radio group's onValueChange) with a
  ref read AND written in the same tick as the activation, released in an effect
  keyed on the open menu — never while a menu stays open, which is the window the
  lock exists for. Stop the repeat one layer earlier as well: on every row,
  preventDefault a keydown whose event.repeat is true for Enter or Space.
- A row may delete the menu it lives in ("Hide the Develop menu"). Its content
  unmounts and the primitive cannot hand focus back, because the title it would
  return to went with it — focus falls to the document body. Remember the id the
  reader was last standing in (set it on trigger focus and on every open) and, in
  an effect keyed on the joined list of menu ids, when that id is gone AND
  document.activeElement is the body, focus the surviving title at the same
  index, clamped to the end of the row. Check the body first: if focus went
  somewhere real, it is not yours to move.
- A menu with no rows renders emptyLabel instead of an empty 4px box; so does an
  empty submenu.
- Panels are portalled, so a bar living in a card, a panel header or a scroller
  with overflow hidden is never clipped, and each panel is capped at
  var(--radix-menubar-content-available-height) so a long menu scrolls instead of
  spilling off a short viewport.

Rendering & styling
- Semantic tokens only: the bar is bg-card inside a border, a title goes
  bg-accent / text-accent-foreground while highlighted or open, panels are
  bg-popover / text-popover-foreground with a border and a shadow, rows highlight
  on accent, shortcut hints and group headings are text-muted-foreground,
  destructive rows are text-destructive over bg-destructive/10, and an
  unavailable title is text-muted-foreground at 60% opacity.
- cn() merges every className. focus-visible:ring-2 ring-ring on the titles;
  inside a menu the primitive moves focus itself, so data-[highlighted] is what
  paints a row.
- Every row of a list that contains any checkbox, radio or icon gets the same
  left gutter, with the check / dot / icon absolutely placed inside it, so labels
  line up whether or not a given row carries a mark.
- Motion is decoration, and it is an ENTRANCE only: a fade plus a 95% zoom plus a
  one-unit slide from the anchored side, all behind motion-safe. Give the panel an
  exit animation and travel breaks — the closing panel stays mounted for the
  length of it, and its dismissable layer reads the panel that just opened taking
  focus as focus leaving it, so hovering or arrowing to the next title dismisses
  the whole bar instead of switching menus. Gate on motion-safe rather than
  motion-reduce:animate-none, too: a data-state variant outranks a bare one, so
  that override never wins. With motion off the menu simply appears.
- ARIA: role="menubar" with an aria-label on the row; each title is a
  role="menuitem" button carrying aria-haspopup="menu", aria-expanded and
  aria-controls; each panel is a role="menu" labelled by its title; rows are
  role="menuitem" / "menuitemcheckbox" / "menuitemradio" with aria-checked; a
  radio group is a role="group" named by its own heading through aria-labelledby.

Customization levers
- Density: titles are px-2.5 py-1 and rows px-2 py-1.5 — take one step off both
  for a compact 28px bar. Nothing is measured in JS, so the layout just follows.
- Sub-blocks are independent: drop the shortcut hint, the icon slot or the gutter
  and the rest keeps working. The union is the only thing to extend when you need
  a new row type (a recent-files header, an avatar row, a colour swatch).
- Placement: align="start" with sideOffset 6 anchors a panel under its title —
  raise the offset for a floating bar, or switch to align="end" for a bar pinned
  to the right edge.
- Tokens: paint the open title with bg-primary / text-primary-foreground for a
  branded bar, or drop the border and bg-card to let the row sit straight on an
  app chrome strip.
- Behaviour: `loop` decides whether the arrow walk wraps at the ends. To keep a
  menu open while several checkboxes are toggled, widen that row's callback to
  receive the primitive's select event and preventDefault() it — closing on
  toggle is the desktop default, not a requirement.
- The data model is the extension point: menus are derived from your own state,
  so permissions ("Develop" unavailable), feature flags (a menu that is not there
  at all) and recent-file lists are ordinary rendering, not special cases.

Concepts

  • Hover travel after the first click — a menubar is modal about the pointer: one click arms the whole row, and from then on moving across a title is enough to switch menus, which is what makes browsing twelve commands cost one press instead of twelve.
  • One tab stop — the row is a single stop in the tab order; ArrowLeft / ArrowRight, Home / End and first-letter typeahead move inside it, so a keyboard user tabs past an entire application menu rather than through it.
  • Refusal without the native attribute — a title is a real button, so disabled would blur it to the document body the moment a permission flips; the unavailable state is aria-disabled plus guards in the pointer and key handlers, which keeps the title reachable and announceable.
  • Skip, never dead-end — an arrow walk that reaches an unavailable title carries on in the same direction instead of stopping, because refusing in place would mean pressing the same key forever against the same grey word.
  • One activation per opening — a held Enter auto-repeats into the highlighted row long before the close has rendered, so the callback is behind a ref read and written in the same tick, released only when the open menu changes.
  • Focus handoff on removal — a row that hides its own menu takes the title focus would have returned to with it, so the bar remembers where the reader was standing and aims at a surviving title when the removal leaves focus on the body.

On This Page