Skip to main content
On this pageOverview

Menu

Overview

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.

Examples

Basic

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 },
})

Animated

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 },
})

Styling

Menu is headless — the itemToConfig callback controls all item markup. Group items with itemGroupKey and groupToHeading.

AttributeCondition
data-openPresent on the button when the menu is open.
data-activePresent on the highlighted menu item.
data-disabledPresent on disabled menu items.
data-closedPresent during close animation.

Keyboard Interaction

Menu uses aria-activedescendant — focus stays on the items container while arrow keys update the highlighted item. Typeahead search accumulates characters for 350ms.

KeyDescription
Enter / SpaceOpens the menu (from button) or selects the active item.
Arrow DownOpens with first item active (from button) or moves to next item.
Arrow UpOpens with last item active (from button) or moves to previous item.
Home / EndMoves to the first / last item.
EscapeCloses the menu and returns focus to the button.
Type a characterTypeahead search — jumps to the matching item.

Accessibility

The button receives aria-haspopup="menu" and aria-expanded. The items container receives role="menu" with aria-activedescendant. Each item receives role="menuitem".

API Reference

InitConfig

Configuration object passed to Menu.init().

NameTypeDefaultDescription
idstring-Unique ID for the menu instance.
isAnimatedbooleanfalseEnables CSS transition coordination.
isModalbooleanfalseLocks page scroll and marks other elements inert when open.

ViewConfig

Configuration object passed to Menu.view().

NameTypeDefaultDescription
modelMenu.Model-The menu state from your parent Model.
toParentMessage(childMessage: Menu.Message) => ParentMessage-Wraps Menu Messages in your parent Message type for Submodel delegation.
itemsReadonlyArray<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.
buttonContentHtml-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.
anchorAnchorConfig-Floating positioning config: placement, gap, and padding.
buttonClassNamestring-CSS class for the trigger button.
itemsClassNamestring-CSS class for the items container.
backdropClassNamestring-CSS class for the backdrop.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson