Menubar
A row of menus that hand off to one another, the way an application menu bar does.
import { signal } from "@implementjs/core";
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarTrigger,
} from "@/lib/components/ui/menubar";
export default function MenubarDemo() {
const wordWrap = signal(true);
const theme = signal<string | null>("system");
return Menubar(
MenubarMenu(
{ value: "file" },
MenubarTrigger("File"),
MenubarContent(
{ class: "w-48" },
MenubarItem({ onSelect: () => console.log("new") }, "New file"),
MenubarItem({ onSelect: () => console.log("open") }, "Open…"),
MenubarSeparator(),
MenubarItem({ onSelect: () => console.log("save") }, "Save"),
MenubarItem({ disabled: true }, "Save all"),
),
),
MenubarMenu(
{ value: "edit" },
MenubarTrigger("Edit"),
MenubarContent(
{ class: "w-48" },
MenubarItem({ onSelect: () => console.log("undo") }, "Undo"),
MenubarItem({ onSelect: () => console.log("redo") }, "Redo"),
MenubarSeparator(),
MenubarItem({ onSelect: () => console.log("find") }, "Find…"),
),
),
MenubarMenu(
{ value: "view" },
MenubarTrigger("View"),
MenubarContent(
{ class: "w-48" },
MenubarCheckboxItem({ checked: wordWrap, closeOnSelect: false }, "Word wrap"),
MenubarSeparator(),
MenubarRadioGroup(
{ value: theme },
MenubarRadioItem({ value: "light" }, "Light"),
MenubarRadioItem({ value: "dark" }, "Dark"),
MenubarRadioItem({ value: "system" }, "System"),
),
),
),
);
}Installation
npx jsrepo add @implementjs/ui/menubar
It installs @implementjs/lucide at the same time.
Copy the file below to src/lib/components/ui/menubar.ts. It imports cn, so copy utils.ts to src/lib/utils.ts too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Span, type Child, type ComponentProps } from "@implementjs/core";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "@implementjs/lucide";
import {
Menubar as MenubarPrimitive,
MenubarCheckboxGroup as MenubarCheckboxGroupPrimitive,
MenubarCheckboxItem as MenubarCheckboxItemPrimitive,
MenubarContent as MenubarContentPrimitive,
MenubarGroup as MenubarGroupPrimitive,
MenubarGroupHeading as MenubarGroupHeadingPrimitive,
MenubarItem as MenubarItemPrimitive,
MenubarMenu as MenubarMenuPrimitive,
MenubarRadioGroup as MenubarRadioGroupPrimitive,
MenubarRadioItem as MenubarRadioItemPrimitive,
MenubarSeparator as MenubarSeparatorPrimitive,
MenubarSub as MenubarSubPrimitive,
MenubarSubContent as MenubarSubContentPrimitive,
MenubarSubTrigger as MenubarSubTriggerPrimitive,
MenubarTrigger as MenubarTriggerPrimitive,
} from "@implementjs/primitives";
import { createComponent } from "@implementjs/primitives";
import { cn } from "@/lib/utils";
export type MenubarProps = ComponentProps<typeof MenubarPrimitive>;
export const Menubar = createComponent(function Menubar(
{ class: className, ...props }: MenubarProps,
...children: Child[]
) {
return MenubarPrimitive(
{
...props,
"data-slot": "menubar",
class: cn(
"flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs",
className,
),
},
...children,
);
});
export type MenubarMenuProps = ComponentProps<typeof MenubarMenuPrimitive>;
export const MenubarMenu = MenubarMenuPrimitive;
export type MenubarTriggerProps = ComponentProps<typeof MenubarTriggerPrimitive>;
export const MenubarTrigger = createComponent(function MenubarTrigger(
{ class: className, ...props }: MenubarTriggerProps,
...children: Child[]
) {
return MenubarTriggerPrimitive(
{
...props,
"data-slot": "menubar-trigger",
class: cn(
"flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className,
),
},
...children,
);
});
export type MenubarContentProps = ComponentProps<typeof MenubarContentPrimitive>;
export const MenubarContent = createComponent(function MenubarContent(
{ class: className, offset = 8, ...props }: MenubarContentProps,
...children: Child[]
) {
return MenubarContentPrimitive(
{
offset,
...props,
"data-slot": "menubar-content",
// no overflow clipping: sub-content panels render nested inside and extend past this box
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"origin-(--ip-menubar-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});
export type MenubarItemProps = ComponentProps<typeof MenubarItemPrimitive>;
export const MenubarItem = createComponent(function MenubarItem(
{ class: className, ...props }: MenubarItemProps,
...children: Child[]
) {
return MenubarItemPrimitive(
{
...props,
"data-slot": "menubar-item",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
);
});
export type MenubarCheckboxGroupProps = ComponentProps<typeof MenubarCheckboxGroupPrimitive>;
export const MenubarCheckboxGroup = MenubarCheckboxGroupPrimitive;
export type MenubarCheckboxItemProps = ComponentProps<typeof MenubarCheckboxItemPrimitive> & {
/**
* The checked indicator, drawn in place of the default check. The left
* padding the default one is absolutely positioned into comes off with it:
* a custom indicator sits in the row's flow, so placing it is yours.
*/
indicator?: Child;
};
export const MenubarCheckboxItem = createComponent(function MenubarCheckboxItem(
{ class: className, indicator, ...props }: MenubarCheckboxItemProps,
...children: Child[]
) {
return MenubarCheckboxItemPrimitive(
{
...props,
"data-slot": "menubar-checkbox-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// room in the gutter for the default check, which is positioned into it
indicator === undefined && "py-1.5 pr-2 pl-8",
className,
),
},
indicator ??
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CheckIcon({
"aria-hidden": true,
class: "size-4 hidden group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type MenubarRadioGroupProps = ComponentProps<typeof MenubarRadioGroupPrimitive>;
export const MenubarRadioGroup = MenubarRadioGroupPrimitive;
export type MenubarRadioItemProps = ComponentProps<typeof MenubarRadioItemPrimitive>;
export const MenubarRadioItem = createComponent(function MenubarRadioItem(
{ class: className, ...props }: MenubarRadioItemProps,
...children: Child[]
) {
return MenubarRadioItemPrimitive(
{
...props,
"data-slot": "menubar-radio-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CircleIcon({
"aria-hidden": true,
class: "size-2 hidden fill-current group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type MenubarGroupProps = ComponentProps<typeof MenubarGroupPrimitive>;
export const MenubarGroup = MenubarGroupPrimitive;
export type MenubarGroupHeadingProps = ComponentProps<typeof MenubarGroupHeadingPrimitive>;
export const MenubarGroupHeading = createComponent(function MenubarGroupHeading(
{ class: className, ...props }: MenubarGroupHeadingProps,
...children: Child[]
) {
return MenubarGroupHeadingPrimitive(
{
...props,
"data-slot": "menubar-group-heading",
class: cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className),
},
...children,
);
});
export type MenubarSeparatorProps = ComponentProps<typeof MenubarSeparatorPrimitive>;
export const MenubarSeparator = createComponent(function MenubarSeparator({
class: className,
...props
}: MenubarSeparatorProps) {
return MenubarSeparatorPrimitive({
...props,
"data-slot": "menubar-separator",
class: cn("-mx-1 my-1 h-px bg-border", className),
});
});
export type MenubarSubProps = ComponentProps<typeof MenubarSubPrimitive>;
export const MenubarSub = MenubarSubPrimitive;
export type MenubarSubTriggerProps = ComponentProps<typeof MenubarSubTriggerPrimitive>;
export const MenubarSubTrigger = createComponent(function MenubarSubTrigger(
{ class: className, ...props }: MenubarSubTriggerProps,
...children: Child[]
) {
return MenubarSubTriggerPrimitive(
{
...props,
"data-slot": "menubar-sub-trigger",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
ChevronRightIcon({ "aria-hidden": true, class: "ml-auto size-4" }),
);
});
export type MenubarSubContentProps = ComponentProps<typeof MenubarSubContentPrimitive>;
export const MenubarSubContent = createComponent(function MenubarSubContent(
{ class: className, offset = 8, ...props }: MenubarSubContentProps,
...children: Child[]
) {
return MenubarSubContentPrimitive(
{
offset,
...props,
"data-slot": "menubar-sub-content",
// submenus pop in without a transition, so they feel instant
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-[state=open]:block",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden",
className,
),
},
...children,
);
});Usage
A menubar is a row of dropdown menus that share focus: once one is open, moving along the bar opens the next without another click, and the arrow keys walk the whole bar.
The bar itself is styled as a bordered strip; the triggers are compact titles that fill in while highlighted or open. The panels and items are the shared menu styles.
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from "@/lib/components/ui/menubar";
Menubar(
MenubarMenu(
{ value: "file" },
MenubarTrigger("File"),
MenubarContent(
{ class: "w-48" },
MenubarItem({ onSelect: newFile }, "New file"),
MenubarSeparator(),
MenubarItem({ disabled: true }, "Save all"),
),
),
);
Every menu needs a value
MenubarMenu takes a value — a stable string the bar tracks as its open menu. Pass a signal as the root's value to open one from outside, or to know which is open.
Styled like the dropdown menu
The panels, items, separators, and indicators are drawn to match the dropdown menu, but the classes live in this file — it installs on its own, and editing it restyles the menubar and nothing else.
API Reference
Every prop the styling does not consume is forwarded to the Menubar primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
Menubar
The bar. Sets role="menubar", owns which menu is open, and moves focus between the triggers. Styled as a bordered bar the menu triggers sit in. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<string | null> | string | null | null | The open menu's value, or null while all are closed. Pass a signal to control it from outside. |
onValueChange | (value: string | null) => void | — | Runs whenever the open menu changes. null once every menu is closed. |
loop | boolean | true | Whether arrow keys wrap from the last trigger back to the first. |
| Data attribute | Value |
|---|---|
[data-menubar-root] | Present |
[data-orientation] | "horizontal" |
MenubarMenu
One menu of the bar. Wraps a trigger and its content.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | — | Identifies the menu. Must be unique within the menubar. |
preventScroll | boolean | true | When true, the page behind cannot scroll while this menu is open. The panel can still scroll if you give it overflow. |
MenubarTrigger
The menu's button in the bar. Sets role="menuitem"; hovering it switches to this menu while another is open. Styled as a compact menu title that fills in while its menu is highlighted or open. Renders a Button; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | Readable<boolean> | boolean | false | Prevents opening the menu. Sets disabled and data-disabled. |
| Data attribute | Value |
|---|---|
[data-menubar-trigger] | Present |
[data-state] | "open" | "closed" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
MenubarContent
The floating panel of items. Sets role="menu". Styled as a popover panel that fades and scales in from the side it opens on, and hides itself when closed. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Preferred side of the anchor to place the panel. |
align | "start" | "center" | "end" | "start" | How the panel aligns along the chosen side. |
offset | number | 8 | Distance in pixels between the anchor and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-menubar-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
MenubarItem
One action. Sets role="menuitem"; focus follows the pointer. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Runs when the item is activated with a click, Enter, or Space. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-menubar-item] | Present |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
MenubarCheckboxGroup
Wraps checkbox items and owns which of them are checked, as one array of values. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<ItemValue[]> | ItemValue[] | [] | The values of the checked items, where ItemValue is string | number. Pass a signal to control them from outside. |
onValueChange | (value: ItemValue[]) => void | — | Runs whenever the set of checked items changes. |
| Data attribute | Value |
|---|---|
[data-menubar-checkbox-group] | Present |
MenubarCheckboxItem
An item holding a checked state. Sets role="menuitemcheckbox" and aria-checked. Renders its own check indicator, shown while the item is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
indicator | Child | — | Drawn in place of the default check. The left padding the default one is absolutely positioned into comes off with it, so a custom indicator sits in the row's flow. |
value | ItemValue | — | Identifies the item inside a checkbox group, as a string or a number. Must be unique within the group; ignored outside one. |
checked | Signal<boolean> | boolean | false | The checked state; selecting toggles it. Pass a signal to control it from outside. Inside a checkbox group the group's value owns it instead. |
onCheckedChange | (checked: boolean) => void | — | Runs whenever the item's checked state changes, inside a group or on its own. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-menubar-checkbox-item] | Present |
[data-value] | The item's value, when it has one |
[data-state] | "checked" | "unchecked" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
MenubarRadioGroup
Wraps radio items and owns which one is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<ItemValue | null> | ItemValue | null | null | The checked item, where ItemValue is string | number. Pass a signal to control it from outside. |
onValueChange | (value: ItemValue | null) => void | — | Runs whenever the checked item changes. null once nothing is checked. |
| Data attribute | Value |
|---|---|
[data-menubar-radio-group] | Present |
MenubarRadioItem
One choice in a radio group. Sets role="menuitemradio" and aria-checked. Renders its own dot indicator, shown while the item is selected. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | ItemValue | — | Identifies the item, as a string or a number. Must be unique within the radio group. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-menubar-radio-item] | Present |
[data-state] | "checked" | "unchecked" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
MenubarSub
A nested menu. Wraps a sub trigger and its sub content inside a parent content.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
onOpenChange | (open: boolean) => void | — | Runs whenever the submenu opens or closes. |
MenubarSubTrigger
The item that opens its submenu — on hover after openDelay, ArrowRight, Enter, Space, or click. Sets aria-haspopup and aria-expanded. The styled trigger appends a chevron pointing into the submenu. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
openDelay | number | 100 | How long the pointer must rest on the trigger before the submenu opens, in milliseconds. |
disabled | Readable<boolean> | boolean | false | Prevents opening the submenu; the keyboard skips the item. |
| Data attribute | Value |
|---|---|
[data-menubar-sub-trigger] | Present |
[data-menubar-item] | Present |
[data-state] | "open" | "closed" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
MenubarSubContent
A submenu's panel. Styled like the content panel but without the transition — a submenu should feel instant. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "right" | Preferred side of the sub trigger to place the panel. |
align | "start" | "center" | "end" | "start" | How the panel aligns along the chosen side. |
offset | number | 8 | Distance in pixels between the sub trigger and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-menubar-sub-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
MenubarGroup
Wraps related items in role="group", labeled by the heading placed inside it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-menubar-group] | Present |
MenubarGroupHeading
Names the group it sits in; the group points aria-labelledby at it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-menubar-group-heading] | Present |
MenubarSeparator
A role="separator" line between sections. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-menubar-separator] | Present |
MenubarPortal
The core Portal helper, for rendering the panel into another DOM parent to escape overflow and stacking.