Desktop-app menubar with shared-open state: click opens, hover moves between menus instantly, arrow keys walk the bar, full shortcut columns.
npx shadcn@latest add @paragon/menubarAlso installs: dropdown-menu
"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,
};