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.
Embed Listbox via the create<Item, Value?>() factory at module scope: const PlansListbox = Ui.Listbox.create<Plan>(). 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.
For programmatic control in update functions, use Listbox.open(model), Listbox.close(model), and Listbox.selectItem(model, item). Each returns [Model, Commands, Option<OutMessage>] 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, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
type Plan = 'Free' | 'Pro' | 'Enterprise'
// Declare a typed Listbox once at module scope. `view` and `update` are
// bound to `Plan`: `items` is typed as `ReadonlyArray<Plan>` and the
// OutMessage carries `value: Plan`.
const PlanListbox = Ui.Listbox.create<Plan>()
// 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({
maybePlan: S.Option(S.String),
listbox: Ui.Listbox.Model,
// ...your other fields
})
// In your init function, initialize the Listbox Submodel with a unique id:
const init = () => [
{
maybePlan: Option.none(),
listbox: Ui.Listbox.init({ id: 'plan' }),
// ...your other fields
},
[],
]
// Wrap Listbox's Messages so they can flow through your update:
const GotListboxMessage = m('GotListboxMessage', {
message: Ui.Listbox.Message,
})
// Inside your update function's M.tagsExhaustive({...}), delegate keyboard
// navigation, typeahead, and open/close to PlanListbox.update. The
// third tuple element is `Option<OutMessage>`; when the user commits a
// selection it carries `Selected({ value })` where `value: Plan`:
GotListboxMessage: ({ message }) => {
const [nextListbox, commands, maybeOutMessage] = PlanListbox.update(
model.listbox,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotListboxMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [evo(model, { listbox: () => nextListbox }), mappedCommands],
onSome: M.type<Ui.Listbox.OutMessage<Plan>>().pipe(
M.tagsExhaustive({
Selected: ({ value }) => [
evo(model, {
listbox: () => nextListbox,
maybePlan: () => Option.some(value),
}),
mappedCommands,
],
}),
),
})
}
const plans: ReadonlyArray<Plan> = ['Free', 'Pro', 'Enterprise']
// Inside your view function, embed the Listbox via h.submodel using
// `PlanListbox.view`:
const view = (model: Model) => {
const h = html<Message>()
return h.submodel({
slotId: 'plan',
model: model.listbox,
view: PlanListbox.view,
viewInputs: {
// `items` must be ReadonlyArray<Plan>. The factory's <Plan> parameter constrains the shape.
items: plans,
buttonContent: h.span(
[],
[Option.getOrElse(model.maybePlan, () => 'Select a plan')],
),
buttonClassName: 'w-full rounded-lg border px-3 py-2 text-left',
itemsClassName: 'rounded-lg border shadow-lg',
itemToConfig: (plan, { isSelected, isActive }) => ({
className: isActive ? 'bg-blue-100' : '',
content: h.div(
[h.Class('flex items-center gap-2 px-3 py-2')],
[
isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
h.span([], [plan]),
],
),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
},
toParentMessage: message => GotListboxMessage({ message }),
})
}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, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// Declare a typed multi-select Listbox once at module scope:
const PeopleListbox = Ui.Listbox.Multi.create<string>()
// 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
},
[],
]
// Wrap Listbox's Messages so they can flow through your update:
const GotListboxMultiMessage = m('GotListboxMultiMessage', {
message: Ui.Listbox.Message,
})
// Delegate keyboard navigation, typeahead, and open/close to
// PeopleListbox.update. The OutMessage's `Selected` carries the toggled
// item and `wasAdded: boolean` indicating whether it was added or
// removed:
GotListboxMultiMessage: ({ message }) => {
const [nextListbox, commands, maybeOutMessage] = PeopleListbox.update(
model.listboxMulti,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotListboxMultiMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [
evo(model, { listboxMulti: () => nextListbox }),
mappedCommands,
],
onSome: M.type<Ui.Listbox.OutMessage>().pipe(
M.tagsExhaustive({
Selected: ({ value, wasAdded }) => [
evo(model, {
listboxMulti: () => nextListbox,
selectedPeople: () =>
wasAdded
? Array.append(model.selectedPeople, value)
: Array.filter(
model.selectedPeople,
person => person !== value,
),
}),
mappedCommands,
],
}),
),
})
}
const people = ['Michael Bluth', 'Lindsay Funke', 'Tobias Funke']
// Inside your view function, embed the Listbox via h.submodel. Multi-select
// stays open on selection so the user can toggle several items:
const view = (model: Model) => {
const h = html<Message>()
return h.submodel({
slotId: 'people',
model: model.listboxMulti,
view: PeopleListbox.view,
viewInputs: {
items: people,
buttonContent: h.span(
[],
[
Array.isReadonlyArrayNonEmpty(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: h.div(
[h.Class('flex items-center gap-2 px-3 py-2')],
[
isSelected ? h.span([], ['✓']) : h.span([h.Class('w-4')], []),
h.span([], [person]),
],
),
}),
backdropClassName: 'fixed inset-0',
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
},
toParentMessage: message => GotListboxMultiMessage({ message }),
})
}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, 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 Character = Readonly<{
firstName: string
lastName: string
}>
const characterName = (character: Character): string =>
`${character.firstName} ${character.lastName}`
// Declare the Listbox once at module scope, typed for the source item
// (`Character`). `view`'s `items` are typed as `ReadonlyArray<Character>`;
// the OutMessage carries the string returned by `itemToValue`:
const CharacterListbox = Ui.Listbox.create<Character>()
// 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.Option(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
},
[],
]
// Wrap Listbox's Messages so they can flow through your update:
const GotListboxMessage = m('GotListboxMessage', {
message: Ui.Listbox.Message,
})
// Inside your update function's M.tagsExhaustive({...}), delegate
// keyboard navigation, typeahead, and open/close to
// CharacterListbox.update. On selection, the OutMessage's `Selected`
// variant carries the chosen item's string value (the result of
// `itemToValue`):
GotListboxMessage: ({ message }) => {
const [nextListbox, commands, maybeOutMessage] = CharacterListbox.update(
model.listbox,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotListboxMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [evo(model, { listbox: () => nextListbox }), mappedCommands],
onSome: M.type<Ui.Listbox.OutMessage>().pipe(
M.tagsExhaustive({
Selected: ({ value }) => [
evo(model, {
listbox: () => nextListbox,
maybeCharacter: () => Option.some(value),
}),
mappedCommands,
],
}),
),
})
}
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:
const view = (model: Model) => {
const h = html<Message>()
return h.submodel({
slotId: 'character',
model: model.listbox,
view: CharacterListbox.view,
viewInputs: {
items: characters,
itemToValue: characterName,
// Group contiguous items by a shared key:
itemGroupKey: character => character.lastName,
// Render a heading for each group:
groupToHeading: lastName => ({
content: h.span([], [`${lastName}s`]),
className: 'px-3 py-1 text-xs font-semibold uppercase text-gray-500',
}),
// Optional separator between groups:
separatorAttributes: childAttributes([h.Class('my-1 border-t')]),
itemToConfig: character => ({
className:
'px-3 py-2 cursor-pointer data-[active]:bg-blue-100 data-[selected]:font-semibold',
content: h.div(
[h.Class('flex items-center gap-2')],
[h.span([], [characterName(character)])],
),
}),
buttonContent: h.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 },
},
toParentMessage: message => GotListboxMessage({ message }),
})
}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 animation 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). |
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. |
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 Listbox.Multi.update.
| Name | Type | Default | Description |
|---|---|---|---|
Selected | { value: Value; wasAdded: boolean } | - | Emitted when an item is committed. Single-select listboxes always emit `wasAdded: true`. Multi-select listboxes emit `wasAdded: true` when adding to the selection and `wasAdded: false` when toggling off. Pattern-match the third tuple element of Listbox.update in your GotListboxMessage handler to lift the value into domain state. |