Skip to main content
On this pageOverview

Combobox

Overview

A searchable select with input filtering, keyboard navigation, and anchor positioning. Unlike Listbox (which uses a button trigger), Combobox has a text input for searching. You control the filtering logic: read model.inputValue and pass the filtered items array. The parent owns the selection: it passes the chosen value in as maybeSelectedValue (multi-select passes selectedValues) along with restingInputValue (the text the input rests at when closed), and folds the Selected and ClearedSelection OutMessages into its own state (single-select stores the value, multi-select toggles the value in its array).

Embed Combobox via the create<Item>() factory at module scope: const CityCombobox = Combobox.create<City>(). The factory binds the view, update, and imperative helpers to the same Item type so the selected value flows through the OutMessage typed end-to-end. Combobox constrains Item extends string.

For programmatic control in update functions, use CityCombobox.open(model), CityCombobox.close(model, restingInputValue), and CityCombobox.selectItem(model, item, displayText). Each returns [Model, Commands, Option<OutMessage>] directly. Single-select close takes the resting input text (the selected display text, or empty); Combobox.Multi closes with close(model) since the multi-select input always rests empty.

See it in an app

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

Examples

Single-Select

Pass itemToValue and itemToDisplayText to control how items map to values and what text appears in the input on selection. Filter the items array yourself based on model.inputValue.

// 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 { Array, Match as M, Option } from 'effect'
import { Command } from 'foldkit'
import { childAttributes, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Combobox } from '@foldkit/ui'

const City = S.Literals(['Johannesburg', 'Kyiv', 'Oxford', 'Wellington'])
type City = typeof City.Type

// Declare a typed Combobox once at module scope:
const CityCombobox = Combobox.create<City>()

// Add a field to your Model for the Combobox Submodel, plus a field for
// the selected value your app actually cares about. Using the `City`
// Schema keeps the field literal-typed end to end:
const Model = S.Struct({
  maybeCity: S.Option(City),
  combobox: Combobox.Model,
  // ...your other fields
})

// In your init function, initialize the Combobox Submodel with a unique id:
const init = () => [
  {
    maybeCity: Option.none(),
    combobox: Combobox.init({ id: 'city' }),
    // ...your other fields
  },
  [],
]

// Wrap Combobox's Messages so they can flow through your update:
const GotComboboxMessage = m('GotComboboxMessage', {
  message: Combobox.Message,
})

// Delegate keyboard navigation, typeahead, and open/close to
// CityCombobox.update. The OutMessage's `Selected` carries the activated
// item; fold it into the selection you own. `ClearedSelection` only fires
// for nullable comboboxes, so this combobox keeps its selection there and
// the fold stays exhaustive:
GotComboboxMessage: ({ message }) => {
  const [nextCombobox, commands, maybeOutMessage] = CityCombobox.update(
    model.combobox,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotComboboxMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [
      evo(model, { combobox: () => nextCombobox }),
      mappedCommands,
    ],
    onSome: M.type<Combobox.OutMessage<City>>().pipe(
      M.tagsExhaustive({
        Selected: ({ value }) => [
          evo(model, {
            combobox: () => nextCombobox,
            maybeCity: () => Option.some(value),
          }),
          mappedCommands,
        ],
        ClearedSelection: () => [
          evo(model, { combobox: () => nextCombobox }),
          mappedCommands,
        ],
      }),
    ),
  })
}

const cities: ReadonlyArray<City> = [
  'Johannesburg',
  'Kyiv',
  'Oxford',
  'Wellington',
]

// Filter items based on the current input value:
const filteredCities =
  model.combobox.inputValue === ''
    ? cities
    : Array.filter(cities, city =>
        city.toLowerCase().includes(model.combobox.inputValue.toLowerCase()),
      )

// Inside your view function, embed the Combobox via h.submodel. Give the
// input an accessible name: target the input id with `Combobox.inputId('city')`
// from a native `<label for>`, and pass `ariaLabelledBy` so the input is named
// by the label. The attribute is only emitted when provided, so the input
// never carries a dangling `aria-labelledby`.
const view = (model: Model) => {
  const h = html<Message>()

  const labelId = 'city-label'

  return h.div(
    [h.Class('flex flex-col gap-1.5')],
    [
      h.label([h.Id(labelId), h.For(Combobox.inputId('city'))], ['City']),
      h.submodel({
        slotId: 'city',
        model: model.combobox,
        view: CityCombobox.view,
        viewInputs: {
          ariaLabelledBy: labelId,
          items: filteredCities,
          // The parent owns the selection; pass it in, plus the text the
          // input rests at when closed (the selected city, or empty):
          maybeSelectedValue: model.maybeCity,
          restingInputValue: Option.getOrElse(model.maybeCity, () => ''),
          itemToValue: city => city,
          itemToDisplayText: city => city,
          itemToConfig: (city, { isSelected }) => ({
            className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
            content: h.div(
              [h.Class('flex items-center gap-2')],
              [
                isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
                h.span([], [city]),
              ],
            ),
          }),
          inputAttributes: childAttributes([
            h.Class('w-full rounded-lg border px-3 py-2'),
            h.Placeholder('Search cities...'),
          ]),
          itemsAttributes: childAttributes([
            h.Class('rounded-lg border shadow-lg'),
          ]),
          backdropAttributes: childAttributes([h.Class('fixed inset-0')]),
          anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
        },
        toParentMessage: message => GotComboboxMessage({ message }),
      }),
    ],
  )
}

Nullable

Pass nullable: true at init to allow clearing the selection by clicking the selected item again, or by emptying the input and closing. Both paths reach the parent as OutMessages (Selected toggles, ClearedSelection clears), so the parent decides what an empty selection looks like.

Select on Focus

Pass selectInputOnFocus: true at init to highlight the input text when the combobox receives focus. Typing immediately replaces the current value, making it easy to start a new search.

Pass selectInputOnFocus: true to highlight the input text when the combobox receives focus. Typing immediately replaces the current value, making it easy to start a new search without manually clearing the input.

Multi-Select

Use Combobox.Multi for multi-selection. The dropdown stays open on selection and items toggle on/off. The parent stores the selected values and folds each Selected OutMessage by toggling the value in its array.

No 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 { Array, Match as M, Option } from 'effect'
import { Command } from 'foldkit'
import { childAttributes, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Combobox } from '@foldkit/ui'

const City = S.Literals(['Johannesburg', 'Kyiv', 'Oxford', 'Wellington'])
type City = typeof City.Type

// Declare a typed multi-select Combobox once at module scope:
const CitiesCombobox = Combobox.Multi.create<City>()

// Add a field to your Model for the Combobox.Multi Submodel, plus a field
// for the selected values your app actually cares about. Using the `City`
// Schema keeps the field literal-typed end to end:
const Model = S.Struct({
  selectedCities: S.Array(City),
  comboboxMulti: Combobox.Multi.Model,
  // ...your other fields
})

// In your init function, initialize the Combobox Submodel with a unique id:
const init = () => [
  {
    selectedCities: [],
    comboboxMulti: Combobox.Multi.init({ id: 'cities-multi' }),
    // ...your other fields
  },
  [],
]

// Wrap Combobox's Messages so they can flow through your update:
const GotComboboxMultiMessage = m('GotComboboxMultiMessage', {
  message: Combobox.Message,
})

// Delegate keyboard navigation, typeahead, and open/close to
// CitiesCombobox.update. Each `Selected` carries the activated item; the
// parent owns the selection, so it toggles the value's membership.
// `ClearedSelection` only fires for nullable comboboxes, so this combobox
// keeps its selection there and the fold stays exhaustive:
GotComboboxMultiMessage: ({ message }) => {
  const [nextCombobox, commands, maybeOutMessage] = CitiesCombobox.update(
    model.comboboxMulti,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotComboboxMultiMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [
      evo(model, { comboboxMulti: () => nextCombobox }),
      mappedCommands,
    ],
    onSome: M.type<Combobox.OutMessage<City>>().pipe(
      M.tagsExhaustive({
        Selected: ({ value }) => [
          evo(model, {
            comboboxMulti: () => nextCombobox,
            selectedCities: () =>
              Array.contains(model.selectedCities, value)
                ? Array.filter(model.selectedCities, city => city !== value)
                : Array.append(model.selectedCities, value),
          }),
          mappedCommands,
        ],
        ClearedSelection: () => [
          evo(model, { comboboxMulti: () => nextCombobox }),
          mappedCommands,
        ],
      }),
    ),
  })
}

const cities: ReadonlyArray<City> = [
  'Johannesburg',
  'Kyiv',
  'Oxford',
  'Wellington',
]

// Filter items based on the current input value:
const filteredCities =
  model.comboboxMulti.inputValue === ''
    ? cities
    : Array.filter(cities, city =>
        city
          .toLowerCase()
          .includes(model.comboboxMulti.inputValue.toLowerCase()),
      )

// Inside your view function, embed the Combobox.Multi via h.submodel. As with
// the single-select Combobox, give the input an accessible name: target the
// input id with `Combobox.Multi.inputId('cities-multi')` from a native
// `<label for>`, and pass `ariaLabelledBy` so the input is named by the label.
// The attribute is only emitted when provided, so the input never carries a
// dangling `aria-labelledby`.
const view = (model: Model) => {
  const h = html<Message>()

  const labelId = 'cities-multi-label'

  return h.div(
    [h.Class('flex flex-col gap-1.5')],
    [
      h.label(
        [h.Id(labelId), h.For(Combobox.Multi.inputId('cities-multi'))],
        ['Cities'],
      ),
      h.submodel({
        slotId: 'cities-multi',
        model: model.comboboxMulti,
        view: CitiesCombobox.view,
        viewInputs: {
          ariaLabelledBy: labelId,
          items: filteredCities,
          // The parent owns the selection; pass it in. The multi-select
          // input always rests empty on close:
          selectedValues: model.selectedCities,
          restingInputValue: '',
          itemToValue: city => city,
          itemToDisplayText: city => city,
          itemToConfig: (city, { isSelected }) => ({
            className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
            content: h.div(
              [h.Class('flex items-center gap-2')],
              [
                isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
                h.span([], [city]),
              ],
            ),
          }),
          inputAttributes: childAttributes([
            h.Class('w-full rounded-lg border px-3 py-2'),
            h.Placeholder('Search cities...'),
          ]),
          itemsAttributes: childAttributes([
            h.Class('rounded-lg border shadow-lg'),
          ]),
          backdropAttributes: childAttributes([h.Class('fixed inset-0')]),
          anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
        },
        toParentMessage: message => GotComboboxMultiMessage({ message }),
      }),
    ],
  )
}

Styling

Combobox is headless. The itemToConfig callback controls all item markup. Style the input, button, items container, and backdrop through their respective attribute props.

The items panel is portaled to the document body and positioned relative to the input wrapper with Floating UI. Ancestor stacking contexts and overflow clipping no longer apply, so a clipped container or a sibling overlay wrapper cannot hide the open panel. The panel still stacks at the document level: give it a z-index above elevated content like sticky headers or toasts, as the demos on this page do with z-10. Pass anchor: { portal: false } to keep the panel inside the wrapper instead.

AttributeCondition
data-activePresent on the item currently highlighted by keyboard or pointer.
data-selectedPresent on the selected item(s).
data-disabledPresent on disabled items.
data-closedPresent during close animation when isAnimated is true.

Keyboard Interaction

Focus stays on the input while arrow keys navigate items via aria-activedescendant.

KeyDescription
Arrow DownOpens the dropdown or moves to the next item.
Arrow UpMoves to the previous item.
EnterSelects the active item.
EscapeCloses the dropdown.
Type a characterFilters the items list. You control filtering in your view by passing filtered items.

Accessibility

The input receives role="combobox" with aria-expanded and aria-activedescendant. The items container receives role="listbox" and each item receives role="option" with aria-selected.

The input is a form field, so give it an accessible name. For a visible label, wire a native <label for> that targets the input id with Combobox.inputId(id) rather than hardcoding the -input convention. The for association makes the input properly labeled: assistive technology announces it by the visible label text, and clicking the label focuses the input. That is why it is the recommended pattern.

Two ViewInputs cover the cases a <label for> does not. Pass ariaLabel when there is no visible label, or ariaLabelledBy when the element that names the input is not a <label> you can point for at.

API Reference

InitConfig

Configuration object passed to Combobox.init() or Combobox.Multi.init().

NameTypeDefaultDescription
idstring-Unique ID for the combobox instance.
isAnimatedbooleanfalseEnables animation coordination.
isModalbooleanfalseLocks page scroll and marks other elements inert when open.
nullablebooleanfalseAllows clearing the selection by clicking the selected item again, or by emptying the input and closing (which emits ClearedSelection).
immediatebooleanfalseEmits Selected on every keyboard activation while open, so arrow keys commit as they move instead of waiting for Enter. Combining immediate with nullable is discouraged: a nullable toggle fold would deselect as the arrows pass back over the selected item.
selectInputOnFocusbooleanfalseHighlights the input text when the combobox receives focus, so typing replaces the current value.

ViewConfig

Configuration object passed to CityCombobox.view.

NameTypeDefaultDescription
modelCombobox.Model-The combobox state from your parent Model.
toParentMessage(childMessage: Combobox.Message) => ParentMessage-Wraps Combobox Messages in your parent Message type for Submodel delegation.
itemsReadonlyArray<Item>-The filtered list of items to display. You control the filtering logic based on model.inputValue.
maybeSelectedValueOption<Item>-The selection the parent owns. None when nothing is selected yet. Multi-select takes selectedValues: ReadonlyArray<Item> instead. Drives the isSelected context and aria-selected.
restingInputValuestring-The text the input returns to when the combobox closes: the selected display text for single-select, an empty string for multi-select.
itemToConfig(item, context) => ItemConfig-Maps each item to its className and content. The context provides isActive, isSelected, and isDisabled.
itemToValue(item: Item, index: number) => Item-Extracts the value from an item. Required.
itemToDisplayText(item: Item, index: number) => string-Text shown in the input when an item is selected. Required.
inputAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the text input.
itemsAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the dropdown items container.
backdropAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the backdrop overlay.
buttonContentHtml-Content for the dropdown toggle button (typically a chevron icon).
buttonAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the toggle button.
anchorAnchorConfig-Floating positioning config: placement, gap, offset, padding, and portal. The items panel is always anchored to the input wrapper; when omitted, the panel uses bottom-start placement. Portaled to the document body by default; pass portal: false to keep the panel inside the wrapper.
ariaLabelstring-Accessible name for the input. Use when there is no visible label. Applied as aria-label, and takes precedence over ariaLabelledBy.
ariaLabelledBystring-Id of an external element that labels the input, applied as aria-labelledby. Pair with a visible label 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. The same shape applies to the update returned by Combobox.Multi.create(), as in CitiesCombobox.update.

NameTypeDefaultDescription
Selected{ value: Item }-Emitted when an item is activated. Carries the neutral fact that the item was activated; the parent owns the selection and decides what it means. Single-select stores the value; multi-select toggles the value in and out of its array. Pattern-match the third tuple element of CityCombobox.update in your GotComboboxMessage handler to fold the value into the selection you own.
ClearedSelection{}-Emitted when a nullable combobox closes with an empty input, meaning the user cleared it. The parent clears the selection it owns.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson