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 the factory’s open(model), close(model), and selectItem(model, item, index) methods. Each returns the same [Model, Commands, Option<OutMessage>] tuple as update.

See it in an app

Check out how Menu is wired up in a real Foldkit app.

Examples

Basic

Pair view and update behind Ui.Menu.create<Item>() at module scope. The factory threads your item union through both, so Selected({ value, index }) carries the picked value directly. 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, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

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

type Action = 'Edit' | 'Duplicate' | 'Archive' | 'Delete'
const actions: ReadonlyArray<Action> = [
  'Edit',
  'Duplicate',
  'Archive',
  'Delete',
]

// Pair view and update behind a single Item-typed factory at module scope:
const ActionMenu = Ui.Menu.create<Action>()

// Inside your update function's M.tagsExhaustive({...}), delegate to
// ActionMenu.update. The OutMessage's `Selected` carries the picked item
// directly (typed as `Action`):
GotMenuMessage: ({ message }) => {
  const [nextMenu, commands, maybeOutMessage] = ActionMenu.update(
    model.menu,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotMenuMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [evo(model, { menu: () => nextMenu }), mappedCommands],
    onSome: M.type<Ui.Menu.OutMessage<Action>>().pipe(
      M.tagsExhaustive({
        Selected: ({ value }) => {
          // The child has emitted `Selected`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example transition a page, mutate domain state, or
          // trigger a downstream Command.
          return [evo(model, { menu: () => nextMenu }), mappedCommands]
        },
      }),
    ),
  })
}

// Inside your view function, render the menu via the factory's view:
const view = () => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'menu',
    model: model.menu,
    view: ActionMenu.view,
    viewInputs: {
      items: actions,
      buttonContent: h.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: h.div([h.Class('px-3 py-2')], [action]),
      }),
      isItemDisabled: action => action === 'Archive',
      backdropClassName: 'fixed inset-0',
      anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
    },
    toParentMessage: message => GotMenuMessage({ message }),
  })
}

Animated

Pass isAnimated: true at init for animation coordination.

// Pseudocode walkthrough using the 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 { html } from 'foldkit/html'
import { m } from 'foldkit/message'

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

// Pair view and update behind a single Item-typed factory at module scope:
const ActionMenu = Ui.Menu.create<Action>()

// Inside your view function, use data-[closed] for enter/leave transitions:
const view = () => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'menu',
    model: model.menu,
    view: ActionMenu.view,
    viewInputs: {
      items: actions,
      buttonContent: h.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: h.div([h.Class('px-3 py-2')], [action]),
      }),
      backdropClassName: 'fixed inset-0',
      anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
    },
    toParentMessage: message => GotMenuMessage({ message }),
  })
}

Styling

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

When isAnimated is true, enter/leave animations flow through the Animation module. Style with CSS transitions or CSS keyframe animations. Animation advances once every animation on the element has settled.

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 animation 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.
buttonContentHtml-Content rendered inside the trigger button.
isItemDisabled((item, index) => boolean) | undefined-Disables individual menu items.
itemToSearchText((item, index) => string) | undefined-Optional override for the string typeahead matches against. Defaults to the item itself; override when items carry searchable text distinct from their display content.
isButtonDisabledboolean | undefined-Disables the trigger button entirely. The menu cannot be opened while true.
itemGroupKey((item, index) => string) | undefined-Groups contiguous items by key.
groupToHeading((groupKey) => GroupHeading | undefined) | undefined-Renders a heading for each group.
anchorAnchorConfig | undefined-Floating positioning config: placement, gap, and padding.
buttonClassNamestring | undefined-CSS class for the trigger button.
buttonAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto the trigger button alongside its built-in click/keyboard handlers and aria-* attributes.
itemsClassNamestring | undefined-CSS class for the items container (the panel root).
itemsAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto the items container.
itemsScrollClassNamestring | undefined-CSS class for the inner scrollable wrapper around the item list. Useful for setting max-height/overflow without restyling the panel root.
itemsScrollAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto the inner scrollable wrapper.
backdropClassNamestring | undefined-CSS class for the backdrop.
backdropAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto the backdrop element.
groupClassNamestring | undefined-CSS class applied to each group wrapper.
groupAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto each group wrapper.
separatorClassNamestring | undefined-CSS class applied to the separator rendered between adjacent groups.
separatorAttributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto each group separator.
classNamestring | undefined-CSS class applied to the outer Menu root element.
attributesReadonlyArray<ChildAttribute> | undefined-Extra attributes spread onto the outer Menu root element.

OutMessage

Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.

NameTypeDefaultDescription
Selected{ value: Item; index: number }-Emitted when a menu item is selected. Carries both the value (typed as your `Item` union via `Menu.create<Item>()`) and its index into the items array supplied at view time. Menu closes itself on selection; the parent does not need to dispatch Menu.close. Pattern-match the third tuple element of Menu.update in your GotMenuMessage handler to dispatch the corresponding domain action.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson