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.

Embed Combobox via the create<Item>() factory at module scope: const CityCombobox = Ui.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), and CityCombobox.selectItem(model, item, displayText). Each returns [Model, Commands, Option<OutMessage>] directly. To mirror an externally-sourced selection without emitting (restoring a draft, a URL), use CityCombobox.reflectSelectedItem(model, maybeItem), which returns the model directly without an OutMessage.

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

type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'

// Declare a typed Combobox once at module scope:
const CityCombobox = Ui.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:
const Model = S.Struct({
  maybeCity: S.Option(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
  },
  [],
]

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

// Delegate keyboard navigation, typeahead, and open/close to
// CityCombobox.update. The OutMessage's `Selected` carries the chosen
// item; lift it into your domain state:
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<Ui.Combobox.OutMessage>().pipe(
      M.tagsExhaustive({
        Selected: ({ value }) => [
          evo(model, {
            combobox: () => nextCombobox,
            maybeCity: () => Option.some(value),
          }),
          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:
const view = () => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'city',
    model: model.combobox,
    view: CityCombobox.view,
    viewInputs: {
      items: filteredCities,
      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.

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

type City = 'Johannesburg' | 'Kyiv' | 'Oxford' | 'Wellington'

// Declare a typed multi-select Combobox once at module scope:
const CitiesCombobox = Ui.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:
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
  },
  [],
]

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

// Delegate keyboard navigation, typeahead, and open/close to
// CitiesCombobox.update. On toggle, the OutMessage's `Selected` carries
// the item and `wasAdded`:
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<Ui.Combobox.OutMessage>().pipe(
      M.tagsExhaustive({
        Selected: ({ value, wasAdded }) => [
          evo(model, {
            comboboxMulti: () => nextCombobox,
            selectedCities: () =>
              wasAdded
                ? Array.append(model.selectedCities, value)
                : Array.filter(model.selectedCities, city => city !== value),
          }),
          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:
const view = () => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'cities-multi',
    model: model.comboboxMulti,
    view: CitiesCombobox.view,
    viewInputs: {
      items: filteredCities,
      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.

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 animation 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 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.
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.

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 Combobox.Multi.update.

NameTypeDefaultDescription
Selected{ value: Item; wasAdded: boolean }-Emitted when an item is committed. Single-select comboboxes always emit `wasAdded: true`. Multi-select comboboxes emit `wasAdded: true` when adding to the selection and `wasAdded: false` when toggling off. Pattern-match the third tuple element of CityCombobox.update in your GotComboboxMessage handler to lift the value into domain state.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson