A menu is an accessible dropdown or context menu used to display a list of actions or options. It uses the aria-activedescendant pattern for focus management and supports typeahead navigation, grouped items, and radio/checkbox option items.
Features
- Supports items, labels, and groups
- Focus managed with
aria-activedescendant
- Typeahead to focus items by typing
- Full keyboard navigation including arrows, Home, End, Page Up/Down
- Radio and checkbox option items
- Submenu support
- Controlled and uncontrolled open state
Installation
npm install @zag-js/menu @zag-js/react
Import the menu package and connect the machine to your framework:
import * as menu from "@zag-js/menu"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"
The menu package exports two key functions:
machine — State machine logic.
connect — Maps machine state to JSX props and event handlers.
import * as menu from "@zag-js/menu"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId } from "react"
export function Menu() {
const service = useMachine(menu.machine, {
id: useId(),
onSelect(details) {
console.log("selected:", details.value)
},
})
const api = menu.connect(service, normalizeProps)
return (
<div>
<button {...api.getTriggerProps()}>Actions â–¼</button>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div {...api.getItemProps({ value: "edit" })}>Edit</div>
<div {...api.getItemProps({ value: "duplicate" })}>Duplicate</div>
<hr {...api.getSeparatorProps()} />
<div {...api.getItemProps({ value: "delete", disabled: true })}>
Delete
</div>
</div>
</div>
</div>
)
}
<script setup>
import * as menu from "@zag-js/menu"
import { useMachine, normalizeProps } from "@zag-js/vue"
import { useId, computed } from "vue"
const service = useMachine(menu.machine, {
id: useId(),
onSelect(details) {
console.log("selected:", details.value)
},
})
const api = computed(() => menu.connect(service, normalizeProps))
</script>
<template>
<div>
<button v-bind="api.getTriggerProps()">Actions â–¼</button>
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<div v-bind="api.getItemProps({ value: 'edit' })">Edit</div>
<div v-bind="api.getItemProps({ value: 'duplicate' })">Duplicate</div>
<hr v-bind="api.getSeparatorProps()" />
<div v-bind="api.getItemProps({ value: 'delete', disabled: true })">Delete</div>
</div>
</div>
</div>
</template>
Listening for item selection
Use onSelect to react when a menu item is clicked.
const service = useMachine(menu.machine, {
onSelect(details) {
// details => { value: string }
console.log("selected value is:", details.value)
},
})
Listening for open state changes
const service = useMachine(menu.machine, {
onOpenChange(details) {
// details => { open: boolean }
console.log("open state is:", details.open)
},
})
Controlled open state
Use open and onOpenChange to control menu visibility externally.
const [open, setOpen] = useState(false)
const service = useMachine(menu.machine, {
open,
onOpenChange(details) {
setOpen(details.open)
},
})
Default open state
Use defaultOpen to start the menu open in uncontrolled mode.
const service = useMachine(menu.machine, {
defaultOpen: true,
})
Group related items with getItemGroupProps and getItemGroupLabelProps.
<div {...api.getContentProps()}>
<p {...api.getItemGroupLabelProps({ htmlFor: "account" })}>Accounts</p>
<div {...api.getItemGroupProps({ id: "account" })}>
<button {...api.getItemProps({ value: "account-1" })}>Account 1</button>
<button {...api.getItemProps({ value: "account-2" })}>Account 2</button>
</div>
<hr {...api.getSeparatorProps()} />
<button {...api.getItemProps({ value: "settings" })}>Settings</button>
</div>
Checkbox and radio option items
Use getOptionItemProps with type: "checkbox" or type: "radio" for toggleable menu items.
const [sort, setSort] = useState("asc")
const [filters, setFilters] = useState({ starred: false, archived: false })
// Radio item — only one can be selected at a time
<div {...api.getOptionItemProps({
type: "radio",
value: "asc",
checked: sort === "asc",
onCheckedChange: () => setSort("asc"),
})}>
Ascending
</div>
<div {...api.getOptionItemProps({
type: "radio",
value: "desc",
checked: sort === "desc",
onCheckedChange: () => setSort("desc"),
})}>
Descending
</div>
// Checkbox item — multiple can be selected
<div {...api.getOptionItemProps({
type: "checkbox",
value: "starred",
checked: filters.starred,
onCheckedChange: (checked) => setFilters((f) => ({ ...f, starred: checked })),
})}>
Starred
</div>
Set closeOnSelect to false to keep the menu open after selecting an item.
const service = useMachine(menu.machine, {
closeOnSelect: false,
})
Setting initial highlighted item
Use defaultHighlightedValue to set the initially highlighted item.
const service = useMachine(menu.machine, {
defaultHighlightedValue: "settings",
})
Positioning
const service = useMachine(menu.machine, {
positioning: { placement: "bottom-start" },
})
Use getContextTriggerProps to attach the menu to a context menu (right-click) trigger.
<div {...api.getContextTriggerProps()}>
Right-click here
</div>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
{/* menu items */}
</div>
</div>
Labeling without visible text
If no visible trigger label exists, provide aria-label.
const service = useMachine(menu.machine, {
"aria-label": "File actions",
})
API Reference
The controlled open state of the menu.
The initial open state when rendered.
Whether to close the menu when an item is selected.
Whether keyboard navigation loops from last item to first and vice versa.
Whether pressing printable characters triggers typeahead navigation.
The controlled highlighted menu item value.
The initial highlighted item value when rendered.
Options for dynamically positioning the menu content.
Accessible label for the menu when no visible label is rendered.
onSelect
(details: { value: string }) => void
Callback invoked when a menu item is selected.
onHighlightChange
(details: { highlightedValue: string | null }) => void
Callback invoked when the highlighted item changes.
onOpenChange
(details: { open: boolean }) => void
Callback invoked when the menu opens or closes.
Styling
Each menu part includes a data-part attribute you can target in CSS.
| Part | Description |
|---|
trigger | The button that opens the menu |
context-trigger | The right-click context menu trigger area |
positioner | Positions the menu content |
content | The menu panel |
item | An individual menu item |
item-text | The text inside a menu item |
item-indicator | Checked indicator for option items |
separator | A visual divider between items or groups |
arrow | Optional arrow element |
Open and closed state
[data-part="content"][data-state="open"] {
/* styles for open state */
}
[data-part="content"][data-state="closed"] {
/* styles for closed state */
}
[data-part="trigger"][data-state="open"] {
/* styles when the menu is open */
}
Highlighted item state
When a menu item is highlighted via keyboard or pointer, data-highlighted is added.
[data-part="item"][data-highlighted] {
background: var(--highlight-color);
}
[data-part="item"][data-type="radio"][data-highlighted] {
background: var(--highlight-color);
}
[data-part="item"][data-type="checkbox"][data-highlighted] {
background: var(--highlight-color);
}
Disabled item state
[data-part="item"][data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
Checked option item state
[data-part="item"][data-type="radio"][data-state="checked"] {
/* styles for checked radio item */
}
[data-part="item"][data-type="checkbox"][data-state="checked"] {
/* styles for checked checkbox item */
}
Style the arrow using CSS variables.
[data-part="arrow"] {
--arrow-size: 8px;
--arrow-background: white;
}
Accessibility
Uses the aria-activedescendant pattern to manage focus movement among menu items without moving DOM focus.
- The menu trigger has
aria-haspopup="menu" and aria-expanded.
- The menu content has
role="menu".
- Each item has
role="menuitem", role="menuitemradio", or role="menuitemcheckbox".
Keyboard interactions
| Key | Description |
|---|
Enter / Space | Opens the menu (on trigger) or activates the highlighted item. |
Escape | Closes the menu and returns focus to the trigger. |
ArrowDown | Opens the menu and moves highlight to the first item; moves to the next item when open. |
ArrowUp | Opens the menu and moves highlight to the last item; moves to the previous item when open. |
Home | Moves highlight to the first item. |
End | Moves highlight to the last item. |
Tab | Closes the menu and moves focus to the next focusable element. |
| Printable characters | Moves highlight to the next item starting with the typed character (typeahead). |