Skip to content

Menu

Menu is a menu button: a trigger that opens a list of actions, following the WAI-ARIA menu button pattern. The keyboard handling, focus management and typeahead come from Zag.js’s menu machine; Sygnal renders it with fromZag. It is a widget tag: render it with a class, and read its events in the intent like any element’s.

import { Menu } from 'sygnal/ui/menu'
const ACTIONS = [
{ value: 'rename', label: 'Rename' },
{ value: 'duplicate', label: 'Duplicate' },
{ separator: true },
{ value: 'delete', label: 'Delete' },
]
function Card({ state }) {
return (
<div className="card">
<h3>{state.title}</h3>
<Menu className="card-actions" label="Actions" items={ACTIONS} />
<p>{state.last ? `Last action: ${state.last}` : ''}</p>
</div>
)
}
Card.initialState = { title: 'Groceries', last: '' }
Card.intent = ({ DOM }) => ({
ACTION: DOM.select('.card-actions').events('select').detail(),
})
Card.model = {
ACTION: (state, last) => ({ ...state, last }),
}

DOM.select('.card-actions').events('select').detail() gives the value of the item the user picked ('rename'). The menu closes and the focus goes back to the trigger.

Each Zag-based part has a subpath of its own (sygnal/ui/menu, sygnal/ui/select, sygnal/ui/combobox), so sygnal/ui (Dialog, Popover, Tabs…) never needs Zag, and a part needs only Zag’s runtime and its own machine. They are optional peer dependencies of sygnal; install them in one version:

Terminal window
npm install @zag-js/vanilla@~1.45.0 @zag-js/menu@~1.45.0 # Menu (sygnal/ui/menu)
npm install @zag-js/vanilla@~1.45.0 @zag-js/select@~1.45.0 # Select (sygnal/ui/select)
npm install @zag-js/vanilla@~1.45.0 @zag-js/combobox@~1.45.0 # Combobox (sygnal/ui/combobox)

Without them, sygnal/vite stops with SYG666, which names the missing packages.

Prop
label(required)The trigger’s text: its accessible name
aria-label, aria-labelledby, aria-describedbyName or describe the trigger when there is no visible label (or in addition to it); they go on the trigger, not the host
items(required)Strings, or { value, label, disabled } objects; { separator: true } draws a separator
openControlled open state; follow the open-change event
defaultOpenfalse
closeOnSelecttruefalse keeps the menu open after a pick
loopFocusfalseThe arrow keys wrap from the last item to the first
typeaheadtrueTyping a letter moves to the next item starting with it
positioningZag’s positioning options: { placement: 'bottom-end', gutter: 4 }
dir'ltr'

className, id, style and aria-* / data-* props go on the host <div>.

EventDetail
selectThe value of the item picked
open-changetrue when it opens, false when it closes

Open or close it from the model with an element command on its class: OPEN_ACTIONS: { ELEMENT: { open: '.card-actions' } }, or { close: '.card-actions' }.

Key
Enter, Space, DownOn the trigger: open the menu on the first item (Up: on the last)
Down / UpThe next / previous item; disabled items are skipped
Home / EndThe first / last item
A letterThe next item that starts with it
Enter, SpacePick the highlighted item and close
Escape, TabClose; Escape returns the focus to the trigger

A click outside closes it too.

The parts carry Zag’s data attributes: [data-scope="menu"] with [data-part="trigger"], "positioner", "content", "item" and "separator"; the content and trigger have data-state="open" | "closed", an item data-highlighted and data-disabled. The content is hidden while closed, and the positioner places it with position and CSS variables (--x, --y).

.card-actions [data-part='content'] { background: Canvas; border: 1px solid #ddd; border-radius: 6px; padding: 4px; }
.card-actions [data-part='item'] { padding: 4px 8px; border-radius: 4px; cursor: default; }
.card-actions [data-part='item'][data-highlighted] { background: rgb(99 102 241 / 0.25); }
.card-actions [data-part='item'][data-disabled] { opacity: 0.5; }

The menu renders inside the widget’s host, next to the trigger, and Zag positions it with position: absolute (the --x / --y variables on the positioner). Inside a container that clips its content (overflow: hidden or auto: a card, a scrolling panel, a table cell), the open menu is cut off at the container’s edge. Pass positioning={{ strategy: 'fixed' }} there: the positioner is then placed relative to the viewport and escapes the clipping (it still follows the trigger when the page scrolls; an ancestor with a transform, filter or contain still clips it). Other options go in the same object: positioning={{ placement: 'bottom-end', gutter: 4, strategy: 'fixed' }}.

In the default mock DOM the menu renders its host only; t.widget('.card-actions') gives its props and dispatches its events:

import { it, expect } from 'vitest'
import { renderComponent } from 'sygnal'
import Card from './Card.jsx'
it('records the picked action', async () => {
const t = renderComponent(Card)
await t.ready()
t.widget('.card-actions').dispatch('select', 'rename')
await t.next((state) => state.last === 'rename')
t.dispose()
})

To test the keyboard and focus, render it with dom: 'real' in jsdom (renderComponent adds the ResizeObserver, CSS.escape and Element.prototype.scrollTo that Zag needs and jsdom lacks, for the test’s lifetime) or in a real browser. Sygnal’s browser suite runs all three Zag parts in Chromium, Firefox and WebKit.

Gzipped, in a small app: Menu adds 33 KB (Zag’s menu machine, its positioning and @zag-js/vanilla make up almost all of it; fromZag is about 2 KB). Menu, Select and Combobox together add 47 KB, because they share most of Zag. For a simple list of links or buttons, a Popover costs a fraction of that.