Combobox
A text input that filters a list of options as you type, for one value or several shown as chips, with async results.
Basic
Type to filter, arrows to move, Enter to pick. Escape puts the selected label back.
Code
const ANIMALS = [
{ value: "tiger", label: "Tiger" },
{ value: "koala", label: "Koala" },
...
];
<Combobox value={form.animal} items={ANIMALS} placeholder="Select an animal" />Usage
import { Combobox, ComboboxItem, ComboboxGroup, ComboboxRow, ComboboxLabel, ComboboxSeparator } from "@elements/ui/combobox";
<Combobox value={form.framework} items={FRAMEWORKS} placeholder="Select a framework" label="Framework" />
<!-- several values, as chips -->
<Combobox multiple values={form.tags} placeholder="Add tags" name="tags">
<ComboboxItem e:for={tag of TAGS} value={tag.id}>{tag.name}</ComboboxItem>
</Combobox>
<!-- async: the caller filters -->
<Combobox inputValue={search.q} filter={null} loading={search.loading} value={form.user}
onInputValueChange={(q) => find(search, q)}>
<ComboboxItem e:for={u of search.results} value={u.id}>{u.name}</ComboboxItem>
</Combobox>Examples
Groups
Groups hide when none of their options match; separators hide while a query filters.
Code
<Combobox value={form.country} placeholder="Select a country">
<ComboboxGroup e:for={g of COUNTRIES} label={g.region}>
<ComboboxItem e:for={[code, name] of g.items} value={code}>{name}</ComboboxItem>
</ComboboxGroup>
<ComboboxSeparator />
<ComboboxItem value="other">Somewhere else</ComboboxItem>
</Combobox>Multiple
Picked values become chips on one line that scrolls sideways; chipsWrap lets them wrap. Backspace in the empty input removes the last chip; Left moves onto the chips.
Code
<Combobox multiple values={form.tags} placeholder="Add labels" name="tags" label="Labels">
<ComboboxItem e:for={tag of TAGS} value={tag}>{tag}</ComboboxItem>
</Combobox>
<Combobox multiple chipsWrap values={form.more} ...>Keep the filter
keepInputOnPick leaves the typed text and the filtered list in place after a pick, so several matches can be picked in a row.
Code
<Combobox multiple keepInputOnPick values={form.tags} items={TAG_ITEMS} label="Labels" />Chip limit
chipLimit shows the first chips and a count of the rest. Every value still submits.
Code
<Combobox multiple chipLimit={2} values={form.tags} items={TAG_ITEMS} name="labels" label="Labels" />Clear button
showClear adds a button that empties the text and the selection. Its space stays reserved, so nothing moves when it appears.
Code
<Combobox showClear value={form.animal} items={ANIMALS} />Auto highlight
autoHighlight keeps the first match highlighted while typing, so Enter picks it.
Code
<Combobox autoHighlight value={form.animal} items={ANIMALS} />Grid
grid lays the options out in ComboboxRow rows: arrows move across rows and columns, and typing filters by each cell's label.
Code
<Combobox grid value={form.emoji} placeholder="Find a fruit or vegetable">
<ComboboxRow e:for={row of EMOJI_ROWS}>
<ComboboxItem e:for={e of row} value={e.id} label={e.id}>
<span aria-hidden="true">{e.char}</span>
<span class="sr-only">{e.id}</span>
</ComboboxItem>
</ComboboxRow>
</Combobox>Custom items
Options take any markup. label sets what the input shows, keywords add words the filter matches, and the empty slot replaces the empty text.
Code
<Combobox value={form.member} placeholder="Assign to">
<ComboboxItem e:for={m of MEMBERS} value={m.id} label={m.name} keywords={m.keywords}>
<span class="avatar">{initials(m.name)}</span>
{m.name}
<span class="meta">{m.role}</span>
</ComboboxItem>
<span slot="empty">Nobody by that name. <a href="/invite">Invite them</a></span>
</Combobox>Async
filter={null} leaves filtering to you. Bind inputValue, fetch on change, set loading while waiting.
Code
function find(state, q) {
state.loading = true;
searchUsers(q).then((users) => { // an @rpc call
state.results = users;
state.loading = false;
});
}
<Combobox value={form.user} inputValue={state.query} filter={null}
loading={state.loading} placeholder="Search people"
onInputValueChange={(q) => find(state, q)}>
<ComboboxItem e:for={u of state.results} value={u.id} label={u.name}>
{u.name} <span class="meta">{u.email}</span>
</ComboboxItem>
</Combobox>Async, multiple
The same search with chips. A chip keeps its label when new results no longer include its item.
Code
<Combobox multiple values={form.people} inputValue={search.query} filter={null}
loading={search.loading} placeholder="Add people"
onInputValueChange={(q) => find(search, q)}>
<ComboboxItem e:for={u of search.results} value={u.id} label={u.name}>
{u.name} <span class="meta">{u.email}</span>
</ComboboxItem>
</Combobox>Virtualized
virtualized renders only the rows in view, so a list of 10,000 options opens and scrolls at once. Typing filters all 10,000 on the client; arrows, PageUp and PageDown still reach every option.
Code
const ITEMS = Array.from({ length: 10000 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Item ${i + 1}`,
}));
<Combobox virtualized value={form.item} items={ITEMS} placeholder="Type to filter 10,000 items" />Inline
inline shows the list in the page under the input, always open, with no popup. It keeps the value, the chips, the form name and the keys; the list holds one height while it filters.
Code
<Combobox inline name="animal" value={form.animal} items={ANIMALS}
label="Animal" placeholder="Filter animals" />
<Combobox inline multiple name="tags" values={form.tags} items={TAG_ITEMS}
label="Labels" placeholder="Filter labels" />Limit and filter
limit caps the matches shown. filter takes any matcher; startsWithFilter matches word starts and endsWithFilter the end of a label.
Code
<Combobox limit={3} value={form.country} items={ALL_COUNTRIES} />
<Combobox filter={startsWithFilter} items={ANIMALS} />
<Combobox filter={endsWithFilter} items={ANIMALS} />Invalid, disabled, read only
invalid shows the danger border; in a Field the label names the input. disabled does nothing; readOnly shows the value without opening.
Code
<Field label="Animal" error={form.animal ? "" : "Pick an animal."}>
<Combobox invalid={!form.animal} value={form.animal} items={ANIMALS} />
</Field>
<Combobox disabled value="tiger" items={ANIMALS} label="Animal" />
<Combobox readOnly value="fox" items={ANIMALS} label="Animal" />Form
name submits the value (not the typed text); multiple submits one entry per value; required blocks an empty submit; reset restores.
Code
<form>
<Combobox name="animal" required value={form.animal} items={ANIMALS} label="Animal" />
<Combobox name="tags" multiple values={form.tags} items={TAG_ITEMS} label="Labels" />
<Button type="submit">Submit</Button>
<Button type="reset">Reset</Button>
</form>Required, multiple
required with multiple blocks an empty submit until at least one value is picked.
Code
<form>
<Combobox multiple required name="labels" values={form.labels} items={LABELS} label="Labels" />
<Button type="submit">Submit</Button>
</form>Outside its form
form ties the value to a form the combobox sits outside of; autocomplete is the autofill hint on the submitted input.
Code
<form id="shipping" onsubmit={save}>
<Button type="submit">Save</Button>
</form>
<Combobox form="shipping" name="country" autocomplete="country-name"
value={form.country} items={COUNTRIES} />Disabled options
A disabled option is shown but the arrows skip it and a click does not pick it.
Code
<Combobox value={form.plan} items={[
{ value: "hobby", label: "Hobby" },
{ value: "team", label: "Team", disabled: true },
...
]} />
<!-- or on an item -->
<ComboboxItem value="team" disabled>Team</ComboboxItem>Input addon
slot="start" puts an icon or text inside the input chrome, before the text, without changing the control's height.
Code
<Combobox value={form.animal} items={ANIMALS} placeholder="Search animals">
<Icon slot="start" name="search" />
</Combobox>Input inside popup
inputInPopup puts the search in the popup and a button in the page that shows the value, like a select you can type in. The value still submits under name.
Code
<form>
<Combobox inputInPopup name="country" value={form.country} items={COUNTRIES}
placeholder="Select a country" searchPlaceholder="Search countries..." />
</form>Controlled
Bind value, inputValue and open. The callbacks report only what the combobox did.
Code
<Combobox value={state.animal} inputValue={state.text} open={state.open}
items={ANIMALS} label="Animal"
onValueChange={(v) => log(v)}
onInputValueChange={(t) => log(t)}
onOpenChange={(o) => log(o)}
onItemHighlighted={(v) => log(v)} />Change details
onValueChange, onInputValueChange and onOpenChange get change details as a second argument: the reason (input-press, item-press, input-change, escape-key, clear-press), the event and cancel().
Code
<Combobox value={form.animal} items={ANIMALS}
onValueChange={(value, details) => log(value, details.reason)}
onOpenChange={(open, details) => log(open, details.reason)} />Object values
A value can be an object. isItemEqualToValue matches the bound value to its item, itemToStringLabel shows it in the input and itemToStringValue submits it. items also takes a record of value to label, or groups.
Code
<Combobox value={form.person}
items={PEOPLE.map((person) => ({ value: person }))}
isItemEqualToValue={(item, value) => item.id === value.id}
itemToStringLabel={(person) => person.name}
itemToStringValue={(person) => String(person.id)}
name="person" />
<Combobox items={{ apple: "Apple", banana: "Banana" }} /> <!-- a record of value to label -->Anchor and boundary
anchor places the popup against another element than the input, and collisionBoundary keeps it inside an element's edges as well as the viewport.
Code
<span id="animal-anchor">Anchor</span>
<Combobox anchor="#animal-anchor" ...> <!-- placed against another element -->
<div class="box">
<Combobox collisionBoundary=".box" ...> <!-- flips and shrinks to stay in the box -->
</div>Options
openOnInputClick={false} opens only from the keyboard or the chevron, here hidden with showTrigger={false}. highlightItemOnHover={false} leaves the highlight to the keyboard. modal locks the page while open; sideOffset and alignOffset move the popup; items with a group field render grouped. value, open and inputValue with no binding set where it starts.
Code
<Combobox openOnInputClick={false} showTrigger={false} items={ANIMALS} /> <!-- a click does not open; no chevron -->
<Combobox highlightItemOnHover={false} items={ANIMALS} /> <!-- the pointer does not move the highlight -->
<Combobox modal sideOffset={8} alignOffset={4} items={LANGUAGES} /> <!-- items with group; scroll locked while open -->
<Combobox value="otter" items={ANIMALS} /> <!-- unbound: the first value -->
<Combobox open={true} inputValue="ot" items={ANIMALS} /> <!-- unbound: open and filtered when it mounts -->Positioning
arrow points at the input from the popup's edge, and arrowPadding keeps it off the corners. collisionAvoidance chooses whether the popup flips or shifts. sticky keeps it in view after the input scrolls away. disableAnchorTracking places it when it opens and on a scroll or resize, not when the page shifts under it. backdrop puts an overlay under it.
Code
<Combobox arrow side="top" sideOffset={10} items={ANIMALS} /> <!-- an arrow on the edge facing the input -->
<Combobox arrow arrowPadding={12} alignOffset={160} items={ANIMALS} /> <!-- the arrow keeps 12px from the corner -->
<Combobox collisionAvoidance={{ side: "none" }} items={ANIMALS} /> <!-- never flips; shrinks instead -->
<Combobox sticky items={ANIMALS} /> <!-- stays in view after the input scrolls away -->
<Combobox disableAnchorTracking items={ANIMALS} /> <!-- placed on open, on scroll and on resize only -->
<Combobox backdrop items={ANIMALS} /> <!-- an overlay under the popup -->Locale
locale sets the case rules the filter folds by. In Turkish, ı and i are different letters: type ı to find Isparta and Iğdır, i to find İstanbul and İzmir.
Code
<Combobox locale="tr" items={CITIES} /> <!-- Turkish case rules: i and dotless ı are different letters -->Sizes
size follows the control height tokens.
Code
<Combobox size="sm" ... />
<Combobox size="lg" ... />RTL
In a right-to-left region the buttons sit on the left and the check on the left of each option.
Code
<Combobox dir="rtl" placeholder="اختر حيوانا" showClear
clearLabel="مسح" triggerLabel="عرض الخيارات" removeLabel="إزالة" ...>
<ComboboxItem value="tiger">نمر</ComboboxItem>
...
</Combobox>