On this pageOverview
Menu
A dropdown menu for actions — like a macOS context menu. Menu is fire-and-forget: it doesn’t track a selected value (use Listbox for persistent selection). It supports typeahead search, drag-to-select, keyboard navigation, grouped items, and anchor positioning.
For programmatic control in update functions, use Menu.open(model), Menu.close(model), and Menu.selectItem(model, index). Each returns [Model, Commands] directly.
See it in an app
Check out how Menu is wired up in a real Foldkit app.
Use onSelectedItem to handle menu item selection. Menu closes automatically after selection.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, div, span } from './html'
// Add a field to your Model for the Menu Submodel:
const Model = S.Struct({
menu: Ui.Menu.Model,
// ...your other fields
})
// In your init function, initialize the Menu Submodel with a unique id:
const init = () => [
{
menu: Ui.Menu.init({ id: 'actions' }),
// ...your other fields
},
[],
]
// Embed the Menu Message in your parent Message:
const GotMenuMessage = m('GotMenuMessage', {
message: Ui.Menu.Message,
})
// Your own Message for handling the selected action:
const SelectedAction = m('SelectedAction', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate to Menu.update:
GotMenuMessage: ({ message }) => {
const [nextMenu, commands] = Ui.Menu.update(model.menu, message)
return [
// Merge the next state into your Model:
evo(model, { menu: () => nextMenu }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(Effect.map(message => GotMenuMessage({ message }))),
),
]
}
type Action = 'Edit' | 'Duplicate' | 'Archive' | 'Delete'
const actions: ReadonlyArray<Action> = [
'Edit',
'Duplicate',
'Archive',
'Delete',
]
// Inside your view function, render the menu:
Ui.Menu.view({
model: model.menu,
toParentMessage: message => GotMenuMessage({ message }),
items: actions,
onSelectedItem: value => SelectedAction({ value }),
buttonContent: span([], ['Options']),
buttonClassName: 'rounded-lg border px-3 py-2 cursor-pointer',
itemsClassName: 'rounded-lg border shadow-lg',
itemToConfig: (action, { isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: div([Class('px-3 py-2')], [action]),
}),
isItemDisabled: action => action === 'Archive',
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})Pass isAnimated: true at init for CSS transition coordination.
// Pseudocode walkthrough — same Model, Messages, and update as the basic
// menu; only init and view change. Each labeled block below is an excerpt.
import { Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { Class, div, span } from './html'
// Only init and view differ from the basic menu: init adds isAnimated, the
// view uses data-[closed] selectors for enter/leave transitions.
// In your init function, set isAnimated: true to coordinate CSS transitions:
const init = () => [
{
menu: Ui.Menu.init({ id: 'actions', isAnimated: true }),
// ...your other fields
},
[],
]
// Embed the Menu Message in your parent Message:
const GotMenuMessage = m('GotMenuMessage', {
message: Ui.Menu.Message,
})
// Inside your view function, use data-[closed] for enter/leave transitions:
Ui.Menu.view({
model: model.menu,
toParentMessage: message => GotMenuMessage({ message }),
items: actions,
onSelectedItem: value => SelectedAction({ value }),
buttonContent: span([], ['Options']),
buttonClassName: 'rounded-lg border px-3 py-2 cursor-pointer',
itemsClassName:
'rounded-lg border shadow-lg transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95',
itemToConfig: (action, { isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: div([Class('px-3 py-2')], [action]),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})Menu is headless — the itemToConfig callback controls all item markup. Group items with itemGroupKey and groupToHeading.
| Attribute | Condition |
|---|---|
data-open | Present on the button when the menu is open. |
data-active | Present on the highlighted menu item. |
data-disabled | Present on disabled menu items. |
data-closed | Present during close animation. |
Menu uses aria-activedescendant — focus stays on the items container while arrow keys update the highlighted item. Typeahead search accumulates characters for 350ms.
| Key | Description |
|---|---|
| Enter / Space | Opens the menu (from button) or selects the active item. |
| Arrow Down | Opens with first item active (from button) or moves to next item. |
| Arrow Up | Opens with last item active (from button) or moves to previous item. |
| Home / End | Moves to the first / last item. |
| Escape | Closes the menu and returns focus to the button. |
| Type a character | Typeahead search — jumps to the matching item. |
The button receives aria-haspopup="menu" and aria-expanded. The items container receives role="menu" with aria-activedescendant. Each item receives role="menuitem".
Configuration object passed to Menu.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the menu instance. |
isAnimated | boolean | false | Enables CSS transition coordination. |
isModal | boolean | false | Locks page scroll and marks other elements inert when open. |
Configuration object passed to Menu.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Menu.Model | - | The menu state from your parent Model. |
toParentMessage | (childMessage: Menu.Message) => ParentMessage | - | Wraps Menu Messages in your parent Message type for Submodel delegation. |
items | ReadonlyArray<Item> | - | The list of menu items. |
itemToConfig | (item, context) => ItemConfig | - | Maps each item to its className and content. The context provides isActive and isDisabled. |
onSelectedItem | (value: string) => Message | - | Fires your Message when an item is selected. Since Menu is fire-and-forget, this is the primary way to handle selection. |
buttonContent | Html | - | Content rendered inside the trigger button. |
isItemDisabled | (item, index) => boolean | - | Disables individual menu items. |
itemGroupKey | (item, index) => string | - | Groups contiguous items by key. |
groupToHeading | (groupKey) => GroupHeading | undefined | - | Renders a heading for each group. |
anchor | AnchorConfig | - | Floating positioning config: placement, gap, and padding. |
buttonClassName | string | - | CSS class for the trigger button. |
itemsClassName | string | - | CSS class for the items container. |
backdropClassName | string | - | CSS class for the backdrop. |