On this pageOverview
Listbox
A custom select dropdown with persistent selection, keyboard navigation, typeahead search, and anchor positioning. Unlike Menu (which is for actions), Listbox tracks the selected value and reflects it in the button. For a searchable input with filtering, use Combobox instead.
For programmatic control in update functions, use Listbox.open(model), Listbox.close(model), and Listbox.selectItem(model, item). Each returns [Model, Commands] directly.
See it in an app
Check out how Listbox is wired up in a real Foldkit app.
Pass an itemToConfig callback that maps each item to its content. The context provides isSelected and isActive for styling the highlighted and selected states.
// 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, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, div, span } from './html'
// Add a field to your Model for the Listbox Submodel, plus a field for
// the selected value your app actually cares about:
const Model = S.Struct({
maybePerson: S.OptionFromSelf(S.String),
listbox: Ui.Listbox.Model,
// ...your other fields
})
// In your init function, initialize the Listbox Submodel with a unique id:
const init = () => [
{
maybePerson: Option.none(),
listbox: Ui.Listbox.init({ id: 'person' }),
// ...your other fields
},
[],
]
// Embed the Listbox Message for keyboard/pointer events, plus your own
// Message for the actual selection:
const GotListboxMessage = m('GotListboxMessage', {
message: Ui.Listbox.Message,
})
const SelectedPerson = m('SelectedPerson', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Listbox.update:
GotListboxMessage: ({ message }) => {
const [nextListbox, commands] = Ui.Listbox.update(model.listbox, message)
return [
// Merge the next state into your Model:
evo(model, { listbox: () => nextListbox }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(Effect.map(message => GotListboxMessage({ message }))),
),
]
}
// Still inside your update function's M.tagsExhaustive({...}), handle your
// own selection Message:
SelectedPerson: ({ value }) => {
// Ui.Listbox.selectItem gives you the next listbox state with the
// selection reflected, plus the Commands that close the dropdown
// and return focus to the button:
const [nextListbox, commands] = Ui.Listbox.selectItem(model.listbox, value)
return [
evo(model, {
maybePerson: () => Option.some(value),
listbox: () => nextListbox,
}),
commands.map(
Command.mapEffect(Effect.map(message => GotListboxMessage({ message }))),
),
]
}
const people = ['Michael Bluth', 'Lindsay Funke', 'Tobias Funke']
// Inside your view function, pass onSelectedItem to fire your SelectedPerson
// Message on selection:
Ui.Listbox.view({
model: model.listbox,
toParentMessage: message => GotListboxMessage({ message }),
onSelectedItem: value => SelectedPerson({ value }),
items: people,
buttonContent: span(
[],
[Option.getOrElse(model.maybePerson, () => 'Select a person')],
),
buttonClassName: 'w-full rounded-lg border px-3 py-2 text-left',
itemsClassName: 'rounded-lg border shadow-lg',
itemToConfig: (person, { isSelected, isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: div(
[Class('flex items-center gap-2 px-3 py-2')],
[
isSelected ? span([], ['✓']) : span([Class('w-4')], []),
span([], [person]),
],
),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})Use Listbox.Multi for multi-selection. The dropdown stays open on selection and items toggle on/off. Selected items are stored in model.selectedItems.
// 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, div, span } from './html'
// Add a field to your Model for the Listbox.Multi Submodel, plus a field
// for the selected values your app actually cares about:
const Model = S.Struct({
selectedPeople: S.Array(S.String),
listboxMulti: Ui.Listbox.Multi.Model,
// ...your other fields
})
// In your init function, initialize the Listbox Submodel with a unique id:
const init = () => [
{
selectedPeople: [],
listboxMulti: Ui.Listbox.Multi.init({ id: 'people' }),
// ...your other fields
},
[],
]
// Embed the Listbox Message for keyboard/pointer events, plus your own
// Message for the actual selection:
const GotListboxMultiMessage = m('GotListboxMultiMessage', {
message: Ui.Listbox.Message,
})
const ToggledPerson = m('ToggledPerson', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Listbox.Multi.update:
GotListboxMultiMessage: ({ message }) => {
const [nextListbox, commands] = Ui.Listbox.Multi.update(
model.listboxMulti,
message,
)
return [
// Merge the next state into your Model:
evo(model, { listboxMulti: () => nextListbox }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(
Effect.map(message => GotListboxMultiMessage({ message })),
),
),
]
}
// Still inside your update function's M.tagsExhaustive({...}), handle your
// own toggle Message:
ToggledPerson: ({ value }) => {
// Ui.Listbox.Multi.selectItem gives you the next listbox state with the
// value toggled in or out of the selection. Multi-select stays open on
// selection, so the returned Commands are empty:
const [nextListbox] = Ui.Listbox.Multi.selectItem(model.listboxMulti, value)
return [
evo(model, {
selectedPeople: () =>
Array.contains(model.selectedPeople, value)
? Array.filter(model.selectedPeople, person => person !== value)
: Array.append(model.selectedPeople, value),
listboxMulti: () => nextListbox,
}),
[],
]
}
const people = ['Michael Bluth', 'Lindsay Funke', 'Tobias Funke']
// Inside your view function, pass onSelectedItem to fire your ToggledPerson
// Message on selection — selectedItems is an array:
Ui.Listbox.Multi.view({
model: model.listboxMulti,
toParentMessage: message => GotListboxMultiMessage({ message }),
onSelectedItem: value => ToggledPerson({ value }),
items: people,
buttonContent: span(
[],
[
Array.isNonEmptyArray(model.selectedPeople)
? `${model.selectedPeople.length} selected`
: 'Select people',
],
),
buttonClassName: 'w-full rounded-lg border px-3 py-2 text-left',
itemsClassName: 'rounded-lg border shadow-lg',
itemToConfig: (person, { isSelected, isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: div(
[Class('flex items-center gap-2 px-3 py-2')],
[
isSelected ? span([], ['✓']) : span([Class('w-4')], []),
span([], [person]),
],
),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})Pass itemGroupKey to group contiguous items by key, and groupToHeading to render section headers. Groups are separated automatically.
// 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, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, div, span } from './html'
// Add a field to your Model for the Listbox Submodel, plus a field for
// the selected value your app actually cares about:
const Model = S.Struct({
maybeCharacter: S.OptionFromSelf(S.String),
listbox: Ui.Listbox.Model,
// ...your other fields
})
// In your init function, initialize the Listbox Submodel with a unique id:
const init = () => [
{
maybeCharacter: Option.none(),
listbox: Ui.Listbox.init({ id: 'character' }),
// ...your other fields
},
[],
]
// Embed the Listbox Message for keyboard/pointer events, plus your own
// Message for the actual selection:
const GotListboxMessage = m('GotListboxMessage', {
message: Ui.Listbox.Message,
})
const SelectedCharacter = m('SelectedCharacter', { value: S.String })
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to Listbox.update:
GotListboxMessage: ({ message }) => {
const [nextListbox, commands] = Ui.Listbox.update(model.listbox, message)
return [
// Merge the next state into your Model:
evo(model, { listbox: () => nextListbox }),
// Forward the Submodel's Commands through your parent Message:
commands.map(
Command.mapEffect(Effect.map(message => GotListboxMessage({ message }))),
),
]
}
// Still inside your update function's M.tagsExhaustive({...}), handle your
// own selection Message:
SelectedCharacter: ({ value }) => {
// Ui.Listbox.selectItem gives you the next listbox state with the
// selection reflected, plus the Commands that close the dropdown
// and return focus to the button:
const [nextListbox, commands] = Ui.Listbox.selectItem(model.listbox, value)
return [
evo(model, {
maybeCharacter: () => Option.some(value),
listbox: () => nextListbox,
}),
commands.map(
Command.mapEffect(Effect.map(message => GotListboxMessage({ message }))),
),
]
}
type Character = Readonly<{
firstName: string
lastName: string
}>
const characterName = (character: Character): string =>
`${character.firstName} ${character.lastName}`
const characters: ReadonlyArray<Character> = [
{ firstName: 'Michael', lastName: 'Bluth' },
{ firstName: 'Gob', lastName: 'Bluth' },
{ firstName: 'George Michael', lastName: 'Bluth' },
{ firstName: 'Lindsay', lastName: 'Funke' },
{ firstName: 'Maeby', lastName: 'Funke' },
{ firstName: 'Tobias', lastName: 'Funke' },
]
// Inside your view function, group items by a key and render a heading for
// each group. Items are grouped in the order they appear — make sure items
// with the same key are contiguous in the items array:
Ui.Listbox.view({
model: model.listbox,
toParentMessage: message => GotListboxMessage({ message }),
onSelectedItem: value => SelectedCharacter({ value }),
items: characters,
itemToValue: characterName,
// Group contiguous items by a shared key:
itemGroupKey: character => character.lastName,
// Render a heading for each group:
groupToHeading: lastName => ({
content: span([], [`${lastName}s`]),
className: 'px-3 py-1 text-xs font-semibold uppercase text-gray-500',
}),
// Optional separator between groups:
separatorAttributes: [Class('my-1 border-t')],
itemToConfig: character => ({
className:
'px-3 py-2 cursor-pointer data-[active]:bg-blue-100 data-[selected]:font-semibold',
content: div(
[Class('flex items-center gap-2')],
[span([], [characterName(character)])],
),
}),
buttonContent: span(
[],
[Option.getOrElse(model.maybeCharacter, () => 'Select a character')],
),
buttonClassName: 'w-full rounded-lg border px-3 py-2 text-left',
itemsClassName: 'rounded-lg border shadow-lg',
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
})Listbox is headless — the itemToConfig callback controls all item markup. Use data-active for the keyboard/pointer highlight and data-selected for the persistent selection indicator.
To make the items panel match the trigger button width, set width: var(--button-width) (or Tailwind w-(--button-width)) on the items class. The anchor system writes the trigger button’s measured width to this CSS variable on the items element every time it positions the panel, so the panel always matches the button even as content or viewport sizes change. Without it, the items panel sizes to its content.
| Attribute | Condition |
|---|---|
data-open | Present on button and wrapper when the dropdown is open. |
data-active | Present on the item currently highlighted by keyboard or pointer. |
data-selected | Present on selected item(s). |
data-disabled | Present on disabled items and on the button when the listbox is disabled. |
data-invalid | Present on the button and wrapper when isInvalid is true. |
data-closed | Present during close animation when isAnimated is true. |
Listbox uses typeahead search — typing printable characters jumps to the first matching item. Characters accumulate for 350ms before the search resets.
| Key | Description |
|---|---|
| Enter / Space | Opens the dropdown (from button) or selects the active item (from items). |
| Arrow Down | Opens with first item active (from button) or moves to next item. |
| Arrow Up | Opens with last item active (from button) or moves to previous item. |
| Home | Moves to the first enabled item. |
| End | Moves to the last enabled item. |
| Escape | Closes the dropdown and returns focus to the button. |
| Type a character | Typeahead search — jumps to the first matching item. Accumulates characters for 350ms. |
The button receives aria-haspopup="listbox" and aria-expanded. The items container receives role="listbox" with aria-activedescendant tracking the highlighted item. Each item receives role="option" with aria-selected.
Configuration object passed to Listbox.init() or Listbox.Multi.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the listbox instance. |
selectedItem | string | - | Initially selected item value (single-select). For multi-select, use selectedItems. |
selectedItems | ReadonlyArray<string> | - | Initially selected item values (multi-select only). |
isAnimated | boolean | false | Enables CSS transition coordination for open/close animations. |
isModal | boolean | false | Locks page scroll and marks other elements inert when open. |
Configuration object passed to Listbox.view(). The same structure is used for Listbox.Multi.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Listbox.Model | - | The listbox state from your parent Model. |
toParentMessage | (childMessage: Listbox.Message) => ParentMessage | - | Wraps Listbox Messages in your parent Message type for Submodel delegation. |
items | ReadonlyArray<Item> | - | The list of items. The generic Item type narrows the value passed to itemToConfig. |
itemToConfig | (item, context) => ItemConfig | - | Maps each item to its className and content. The context provides isActive, isSelected, and isDisabled. |
buttonContent | Html | - | Content rendered inside the listbox button (typically the selected value). |
onSelectedItem | (value: string) => Message | - | Alternative to Submodel delegation — fires your own Message on selection. Use with Listbox.selectItem() to update the Model while also handling domain logic. |
itemToValue | (item: Item) => string | - | Extracts the string value from an item. Defaults to String(item). |
isItemDisabled | (item, index) => boolean | - | Disables individual items. |
itemGroupKey | (item, index) => string | - | Groups contiguous items by key. Use with groupToHeading to render section headers. |
groupToHeading | (groupKey) => GroupHeading | undefined | - | Renders a heading for each group. |
anchor | AnchorConfig | - | Floating positioning config: placement, gap, and padding. |
name | string | - | Form field name. Creates hidden input(s) with the selected value(s). |
isDisabled | boolean | false | Disables the entire listbox. |
isInvalid | boolean | false | Marks the listbox as invalid for validation styling. |