Skip to content
Folio

Overlays

SearchableMenu

A menu of actions you can type into.

Usage

When to reach for it

A DropdownMenu past about a dozen rows stops being scannable, and nesting submenus makes it worse. This is the same list with a filter over it. Not a Command palette: that is page-level and modal; this is anchored to a control.
TSX
import { SearchableMenu } from '@misoto22/design'

Notes

A menu of actions you can type into.

The fourth corner of a square the system otherwise had three of, and the distinction is worth stating because reaching for the wrong one is easy:

| | few options | many options | |---|---|---| | sets a value | Select | Combobox | | runs an action | DropdownMenu | SearchableMenu |

A DropdownMenu past about a dozen rows stops being scannable, and the usual response — nesting submenus — makes it worse. This is the same list with a filter over it.

Not the same thing as Command: that is a page-level palette, usually modal and usually bound to ⌘K. This is anchored to a control, like the menu it replaces.

The rows are options inside a listbox rather than menuitems, because that is what the filtering pattern requires — the highlight moves through aria-activedescendant while focus stays in the input, and a menu cannot do that. The trade is deliberate: a menu that cannot be filtered is worse for the reader than a listbox that runs actions.

Anatomy

SearchableMenu anatomy
ElementDescription
TriggerrequiredA pill button built here rather than a Button: --control-h-sm tall, hairline bordered, with a chevron. Its accessible name is label, not the children you passed as its text.
PanelrequiredA Popover with its padding removed and a flat 16rem width, so the filter and the list run edge to edge inside it. Not modal — this is a Popover, not a Dialog.
FilterrequiredThe Command input. Focus lands here on open and stays there; the highlight moves under it through aria-activedescendant.
RowsOne per MenuAction, as listbox options. shortcut prints as a Kbd at the end of the row, destructive paints it --danger, and selecting one closes the menu before running onSelect.
Empty stateemptyMessage, shown when the filter matches nothing. Say what would match rather than “no results”.

Best practices

Do

  • Give an action keywords for the word a reader reaches for that the label does not print — “download” for Export, “bin” for Delete. The label’s own text is lifted into the filter for you; a label built only from elements prints none, and development warns SEARCHABLE_MENU_LABEL_UNREADABLE rather than shipping a row nothing matches.
  • Make label the trigger’s visible text. It is set as aria-label on the trigger and overrides the children, so a button reading “Status” under a label of “Row actions” is announced as something the reader cannot say aloud (WCAG 2.5.3).
  • Keep actions that share a group next to each other in the array: groups are built by walking the list and extending only the LAST one, so the same group name appearing again after other rows produces a second heading with identical text.

Don’t

  • It is a Popover underneath, so it is not modal: the page behind still scrolls while the filter is open and the panel is re-anchored as it does. A surface that should hold the page still is CommandDialog.
  • className lands on the TRIGGER, not on the panel — the panel is a flat 16rem with no prop that widens it, so a long label wraps to a second line instead of the box growing to take it.
  • The trigger is --control-h-sm — 36px comfortable, 30px under data-density="compact" — which is below the 44px pointer target (WCAG 2.5.5), and it is not the system Button, so variant and size do not reach it.

Examples

a filtered menu

A DropdownMenu past about a dozen rows stops being scannable, and nesting submenus makes it worse; this is the same list with a filter over it. Not a Command palette — that is page-level and modal, bound to a key. This is anchored to a control, like the menu it replaces, and underneath it IS a Popover, so the page behind still scrolls while the filter is open. The rows are listbox options rather than menuitems, because filtering requires it: the highlight moves through aria-activedescendant while focus stays in the input, and a menu cannot do that.

nine actions, one filter

searchable keywords

The filter matches on the row's value, and this component passes action.id as that value — so with ids like act_7f2 the visible label matches nothing and typing what you can see returns the empty state. keywords is the fix, and it is also where the words a reader would reach for but nobody wrote go: someone looking for the CSV export types spreadsheet, and someone rolling a release back types undo. Type either into the filter below and watch the right row survive.

four opaque ids, matched by their keywords

groups in order

Groups are built by walking the array once and extending only the LAST one, so a group name that appears again after other rows produces a second heading with identical text rather than joining the first. Keep the members of a group adjacent, as they are here, and the arrangement is controlled by the order you write them in — there is no ordering prop, deliberately. Two other things travel with the row: shortcut prints as a keycap at its end, and destructive paints it. The trigger's accessible name is label, not the text you passed as children, so make the two say the same thing.

two groups, written adjacent

Keyboard

SearchableMenu keyboard interactions
KeyDoes
EnterSpaceOpens the menu.
Moves the highlight while focus stays in the filter.
EnterRuns the highlighted action.
EscapeCloses without running anything.

Accessibility

  • Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
  • The rows are options inside a listbox rather than menuitems, because filtering requires it — the highlight moves through aria-activedescendant while focus stays in the input, and a menu cannot do that.
  • The trade is deliberate: a menu that cannot be filtered is worse for the reader than a listbox that runs actions.