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.

For programmatic control in update functions, use Combobox.open(model), Combobox.close(model), and Combobox.selectItem(model, item, displayText). Each returns [Model, Commands] directly.

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, Effect, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, Placeholder, div, span } from './html'

// Add a field to your Model for the Combobox Submodel, plus a field for
// the selected value your app actually cares about:
const Model = S.Struct({
  maybeCity: S.OptionFromSelf(S.String),
  combobox: Ui.Combobox.Model,
  // ...your other fields
})

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

// Embed the Combobox Message for keyboard/input events, plus your own
// Message for the actual selection:
const GotComboboxMessage = m('GotComboboxMessage', {
  message: Ui.Combobox.Message,
})
const SelectedCity = m('SelectedCity', { value: S.String })

// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Combobox.update:
GotComboboxMessage: ({ message }) => {
  const [nextCombobox, commands] = Ui.Combobox.update(model.combobox, message)

  return [
    // Merge the next state into your Model:
    evo(model, { combobox: () => nextCombobox }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(Effect.map(message => GotComboboxMessage({ message }))),
    ),
  ]
}

// Still inside your update function's M.tagsExhaustive({...}), handle your
// own selection Message:
SelectedCity: ({ value }) => {
  // Ui.Combobox.selectItem gives you the next combobox state with the
  // selection reflected (input value updated, dropdown closed), plus
  // the Commands that return focus to the input. Single-select combobox
  // takes both the item value and the display text:
  const [nextCombobox, commands] = Ui.Combobox.selectItem(
    model.combobox,
    value,
    value,
  )

  return [
    evo(model, {
      maybeCity: () => Option.some(value),
      combobox: () => nextCombobox,
    }),
    commands.map(
      Command.mapEffect(Effect.map(message => GotComboboxMessage({ message }))),
    ),
  ]
}

type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
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, pass onSelectedItem to fire your SelectedCity
// Message on selection:
Ui.Combobox.view({
  model: model.combobox,
  toParentMessage: message => GotComboboxMessage({ message }),
  onSelectedItem: value => SelectedCity({ value }),
  items: filteredCities,
  itemToValue: city => city,
  itemToDisplayText: city => city,
  itemToConfig: (city, { isSelected }) => ({
    className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
    content: div(
      [Class('flex items-center gap-2')],
      [
        isSelected ? span([], ['✓']) : span([Class('w-4')], []),
        span([], [city]),
      ],
    ),
  }),
  inputAttributes: [
    Class('w-full rounded-lg border px-3 py-2'),
    Placeholder('Search cities...'),
  ],
  itemsAttributes: [Class('rounded-lg border shadow-lg')],
  backdropAttributes: [Class('fixed inset-0')],
  anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
})

Nullable

Pass nullable: true at init to allow clearing the selection by clicking the selected item again.

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. Selected items are stored in model.selectedItems.

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, Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, Placeholder, div, span } from './html'

// Add a field to your Model for the Combobox.Multi Submodel, plus a field
// for the selected values your app actually cares about:
const Model = S.Struct({
  selectedCities: S.Array(S.String),
  comboboxMulti: Ui.Combobox.Multi.Model,
  // ...your other fields
})

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

// Embed the Combobox Message for keyboard/input events, plus your own
// Message for the actual selection:
const GotComboboxMultiMessage = m('GotComboboxMultiMessage', {
  message: Ui.Combobox.Message,
})
const ToggledCity = m('ToggledCity', { value: S.String })

// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Combobox.Multi.update:
GotComboboxMultiMessage: ({ message }) => {
  const [nextCombobox, commands] = Ui.Combobox.Multi.update(
    model.comboboxMulti,
    message,
  )

  return [
    // Merge the next state into your Model:
    evo(model, { comboboxMulti: () => nextCombobox }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(
        Effect.map(message => GotComboboxMultiMessage({ message })),
      ),
    ),
  ]
}

// Still inside your update function's M.tagsExhaustive({...}), handle your
// own toggle Message:
ToggledCity: ({ value }) => {
  // Ui.Combobox.Multi.selectItem gives you the next combobox state with
  // the value toggled in or out of the selection. Multi-select stays open
  // on selection, so the returned Commands are empty:
  const [nextCombobox] = Ui.Combobox.Multi.selectItem(
    model.comboboxMulti,
    value,
  )

  return [
    evo(model, {
      selectedCities: () =>
        Array.contains(model.selectedCities, value)
          ? Array.filter(model.selectedCities, city => city !== value)
          : Array.append(model.selectedCities, value),
      comboboxMulti: () => nextCombobox,
    }),
    [],
  ]
}

type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'
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, pass onSelectedItem to fire your ToggledCity
// Message on selection:
Ui.Combobox.Multi.view({
  model: model.comboboxMulti,
  toParentMessage: message => GotComboboxMultiMessage({ message }),
  onSelectedItem: value => ToggledCity({ value }),
  items: filteredCities,
  itemToValue: city => city,
  itemToDisplayText: city => city,
  itemToConfig: (city, { isSelected }) => ({
    className: 'px-3 py-2 cursor-pointer data-[active]:bg-blue-100',
    content: div(
      [Class('flex items-center gap-2')],
      [
        isSelected ? span([], ['✓']) : span([Class('w-4')], []),
        span([], [city]),
      ],
    ),
  }),
  inputAttributes: [
    Class('w-full rounded-lg border px-3 py-2'),
    Placeholder('Search cities...'),
  ],
  itemsAttributes: [Class('rounded-lg border shadow-lg')],
  backdropAttributes: [Class('fixed inset-0')],
  anchor: { placement: 'bottom-start', gap: 8, padding: 8 },
})

Styling

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

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.

API Reference

InitConfig

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

NameTypeDefaultDescription
idstring-Unique ID for the combobox instance.
selectedItemstring-Initially selected item value (single-select only).
selectedDisplayTextstring-Initial display text in the input (single-select only).
isAnimatedbooleanfalseEnables CSS transition coordination.
isModalbooleanfalseLocks page scroll and marks other elements inert when open.
nullablebooleanfalseAllows clearing the selection by clicking the selected item again.
selectInputOnFocusbooleanfalseHighlights the input text when the combobox receives focus, so typing replaces the current value.

ViewConfig

Configuration object passed to Combobox.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.
onSelectedItem(value: string) => ParentMessage-Alternative to Submodel delegation — fires your own Message on selection. Use with Combobox.selectItem() in your update handler to reflect the selection in the combobox state.
itemsReadonlyArray<Item>-The filtered list of items to display. You control the filtering logic based on model.inputValue.
itemToConfig(item, context) => ItemConfig-Maps each item to its className and content. The context provides isActive, isSelected, and isDisabled.
itemToValue(item: Item) => string-Extracts the string value from an item.
itemToDisplayText(item: Item) => string-Text shown in the input when an item is selected.
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, and padding.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson