Menubar
Navigation

Menubar

Desktop-app menubar with shared-open state: click opens, hover moves between menus instantly, arrow keys walk the bar, full shortcut columns.

Install

npx shadcn@latest add @paragon/menubar

Also installs: dropdown-menu

menubar.tsx

"use client";

import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
  DropdownMenuCheckboxItem,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";

/*
 * Menubar physics: the FIRST open animates like a dropdown (0.97 scale,
 * 150ms ease-out). Moving between menus — the defining menubar gesture —
 * is instant: no exit, no enter, exactly like a native app menubar.
 * Closing for real gets the standard non-retracing 100ms exit.
 */
const menubarStyles = `
@keyframes pg-menubar-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-menubar-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-menubar-in { from { opacity: 0; } }
  @keyframes pg-menubar-out { to { opacity: 0; } }
}
`;

interface MenubarContextValue {
  /** Value of the currently open menu, or null. */
  open: string | null;
  /** True when the last change moved directly between two menus. */
  moved: boolean;
  openMenu: (value: string | null) => void;
  /** Registered menu values in DOM order, for ArrowLeft/ArrowRight. */
  values: string[];
  register: (value: string) => () => void;
  /** Latest open value, readable inside close callbacks. */
  openRef: React.RefObject<string | null>;
  /** Roving-tabindex focus target when the bar itself is tabbed into. */
  focusValue: string | null;
  setFocusValue: (value: string) => void;
}

const MenubarContext = React.createContext<MenubarContextValue | null>(null);
const MenuValueContext = React.createContext<string>("");

function useMenubar(component: string) {
  const context = React.useContext(MenubarContext);
  if (!context) {
    throw new Error(`<${component}> must be used within <Menubar>`);
  }
  return context;
}

export interface MenubarProps extends React.ComponentProps<"div"> {}

/**
 * Desktop-app menubar composed from the dropdown-menu primitives with one
 * shared-open state: click opens a menu, after which pointer hover MOVES the
 * open menu across triggers without closing — and the swap is instant, the
 * way native menubars behave. ArrowLeft/ArrowRight walk menus while open
 * (and roam triggers while closed); Escape closes and returns focus.
 */
export function Menubar({ className, children, ...props }: MenubarProps) {
  const [state, setState] = React.useState<{
    open: string | null;
    moved: boolean;
  }>({ open: null, moved: false });
  const [values, setValues] = React.useState<string[]>([]);
  const [focusValue, setFocusValue] = React.useState<string | null>(null);
  const openRef = React.useRef<string | null>(null);
  const barRef = React.useRef<HTMLDivElement>(null);

  const openMenu = React.useCallback((next: string | null) => {
    openRef.current = next;
    setState((prev) => ({
      open: next,
      moved: prev.open !== null && next !== null && prev.open !== next,
    }));
  }, []);

  const register = React.useCallback((value: string) => {
    setValues((prev) => (prev.includes(value) ? prev : [...prev, value]));
    return () =>
      setValues((prev) => prev.filter((existing) => existing !== value));
  }, []);

  // Roving focus across triggers while the bar is closed.
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (state.open !== null) return;
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
    const bar = barRef.current;
    if (!bar) return;
    const triggers = Array.from(
      bar.querySelectorAll<HTMLElement>("[data-menubar-trigger]"),
    );
    if (triggers.length === 0) return;
    const current = triggers.indexOf(document.activeElement as HTMLElement);
    let next = current;
    if (event.key === "ArrowRight")
      next = (current + 1 + triggers.length) % triggers.length;
    if (event.key === "ArrowLeft")
      next = (current - 1 + triggers.length) % triggers.length;
    if (event.key === "Home") next = 0;
    if (event.key === "End") next = triggers.length - 1;
    triggers[next]?.focus();
    event.preventDefault();
  };

  const contextValue = React.useMemo<MenubarContextValue>(
    () => ({
      open: state.open,
      moved: state.moved,
      openMenu,
      values,
      register,
      openRef,
      focusValue,
      setFocusValue,
    }),
    [state.open, state.moved, openMenu, values, register, focusValue],
  );

  return (
    <MenubarContext.Provider value={contextValue}>
      <div
        ref={barRef}
        role="menubar"
        data-slot="menubar"
        onKeyDown={onKeyDown}
        className={cn(
          "inline-flex items-center gap-0.5 rounded-lg bg-card p-1 shadow-border",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </MenubarContext.Provider>
  );
}

export interface MenubarMenuProps {
  /** Unique value identifying this menu within the bar. */
  value: string;
  children: React.ReactNode;
}

export function MenubarMenu({ value, children }: MenubarMenuProps) {
  const { open, openMenu, register } = useMenubar("MenubarMenu");

  React.useLayoutEffect(() => register(value), [register, value]);

  return (
    <MenuValueContext.Provider value={value}>
      {/* modal=false so pointer events reach sibling triggers while open. */}
      <DropdownMenuPrimitive.Root
        modal={false}
        open={open === value}
        onOpenChange={(next) => {
          if (next) openMenu(value);
          else if (open === value) openMenu(null);
        }}
      >
        {children}
      </DropdownMenuPrimitive.Root>
    </MenuValueContext.Provider>
  );
}

export function MenubarTrigger({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
  const { open, openMenu, values, focusValue, setFocusValue } =
    useMenubar("MenubarTrigger");
  const value = React.useContext(MenuValueContext);
  const tabTarget = focusValue ?? values[0];

  return (
    <DropdownMenuPrimitive.Trigger
      data-slot="menubar-trigger"
      data-menubar-trigger=""
      role="menuitem"
      tabIndex={value === tabTarget ? 0 : -1}
      onFocus={() => setFocusValue(value)}
      // A menu is already open: hovering another trigger moves it there.
      onPointerEnter={() => {
        if (open !== null && open !== value) openMenu(value);
      }}
      className={cn(
        "h-7 rounded-md px-2.5 text-[13px] font-medium whitespace-nowrap text-foreground/90 outline-none select-none",
        "transition-[background-color,color] duration-150 ease-out",
        "hover:bg-accent/70 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
        className,
      )}
      {...props}
    />
  );
}

export function MenubarContent({
  className,
  sideOffset = 6,
  align = "start",
  onKeyDown,
  onCloseAutoFocus,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
  const { moved, openMenu, values, openRef } = useMenubar("MenubarContent");
  const value = React.useContext(MenuValueContext);

  return (
    <>
      {/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
          child node, and the Radix menu Portal enforces a single child. */}
      <style href="paragon-menubar" precedence="paragon">
        {menubarStyles}
      </style>
      <DropdownMenuPrimitive.Portal>
        <DropdownMenuPrimitive.Content
          data-slot="menubar-content"
          sideOffset={sideOffset}
          align={align}
          onKeyDown={(event) => {
            onKeyDown?.(event);
            // Submenus preventDefault their own arrow handling first.
            if (event.defaultPrevented) return;
            if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
            const direction = event.key === "ArrowRight" ? 1 : -1;
            const index = values.indexOf(value);
            if (index === -1 || values.length < 2) return;
            openMenu(values[(index + direction + values.length) % values.length]);
            event.preventDefault();
          }}
          onCloseAutoFocus={(event) => {
            onCloseAutoFocus?.(event);
            // When the open menu moved to a sibling, let the new content take
            // focus instead of yanking it back to this trigger.
            if (openRef.current !== null) event.preventDefault();
          }}
          className={cn(
            "z-50 min-w-52 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
            moved
              ? "data-[state=open]:animate-none data-[state=closed]:animate-none"
              : "data-[state=open]:animate-[pg-menubar-in_150ms_var(--ease-out)] data-[state=closed]:animate-[pg-menubar-out_100ms_var(--ease-exit)_forwards]",
            className,
          )}
          {...props}
        />
      </DropdownMenuPrimitive.Portal>
    </>
  );
}

/* Item-level parts share the dropdown-menu physics and styling exactly. */
const MenubarItem = DropdownMenuItem;
const MenubarCheckboxItem = DropdownMenuCheckboxItem;
const MenubarRadioGroup = DropdownMenuRadioGroup;
const MenubarRadioItem = DropdownMenuRadioItem;
const MenubarLabel = DropdownMenuLabel;
const MenubarSeparator = DropdownMenuSeparator;
const MenubarShortcut = DropdownMenuShortcut;
const MenubarGroup = DropdownMenuGroup;
const MenubarSub = DropdownMenuSub;
const MenubarSubTrigger = DropdownMenuSubTrigger;
const MenubarSubContent = DropdownMenuSubContent;

export {
  MenubarItem,
  MenubarCheckboxItem,
  MenubarRadioGroup,
  MenubarRadioItem,
  MenubarLabel,
  MenubarSeparator,
  MenubarShortcut,
  MenubarGroup,
  MenubarSub,
  MenubarSubTrigger,
  MenubarSubContent,
};