Combobox
An editable text input combined with a popup listbox. The user types a query,
the consumer filters and renders matching options, and the component handles
navigation, ARIA wiring, and selection commit. It ships unstyled: style it
through the data-state, data-highlighted, data-selected, data-disabled,
data-side, data-align, and data-empty contract.
Use a Combobox when the user needs to search or filter before picking. Use Select when the list is short and always fully visible without filtering.
The consumer owns filtering. The component never filters its children.
Read inputValue (the typed query) and render only the matching
<Combobox.Option> elements. A matchSubstring helper is exported for the
common in-memory case.
inputValue is always the typed query. In autocomplete="both" mode the
DOM input may show a longer inline completion, but the public inputValue
(and onInputChange) is always the prefix the user typed, so consumer
filtering is identical across all three autocomplete modes.
Demo
The minimal form is a single Combobox.Input. Focus it (or click it) to open,
type to filter; the consumer renders the matching options while the component
handles navigation and selection. A single select commits and closes on pick.
Multiple selection shows the two optional parts: Combobox.Anchor wraps the
chips and input into one bordered field (the popup aligns to it and clicks in it
are dismiss-safe), and Combobox.Trigger is the chevron toggle. Picking keeps
the popup open; Combobox.Value renders the chips; Backspace on an empty input
removes the last one.
Creatable: when nothing matches, a "Create …" option appears and adds the new
value through onCreate.
With autocomplete="both", the input inline-completes to the first match; Enter
or Tab accepts it, Backspace dismisses it and keeps typing.
Usage
import { Combobox, matchSubstring } from '@hono-preact/ui';
import { useState } from 'preact/hooks';
const FRUITS = ['Apple', 'Banana', 'Cherry', 'Orange', 'Lemon'];
export function FruitCombobox() {
const [query, setQuery] = useState('');
const filtered = FRUITS.filter((f) => matchSubstring(f, query));
return (
<Combobox.Root onInputChange={setQuery}>
<Combobox.Input placeholder="Search fruit…" aria-label="Fruit" />
<Combobox.Status />
<Combobox.Positioner>
<Combobox.Popup aria-label="Fruit">
{filtered.map((f) => (
<Combobox.Option key={f} value={f}>
{f}
</Combobox.Option>
))}
<Combobox.Empty>No results</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Root>
);
}
The only required part is Combobox.Input (it carries role="combobox"); it
opens on focus by default and anchors the popup to itself. Everything else is an
opt-in. The combobox is uncontrolled by default; pass value / onValueChange,
open / onOpenChange, or inputValue / onInputChange to control any piece.
Field wrapper + trigger (optional). For a bordered field that holds chips or
adornments, wrap the input in Combobox.Anchor: the popup then aligns to the
whole field and clicks anywhere in it are dismiss-safe. Add Combobox.Trigger
for an explicit chevron toggle button (focus already opens the popup, so this is
only needed when you want a dedicated open/close control):
<Combobox.Anchor>
<Combobox.Input aria-label="Fruit" />
<Combobox.Trigger aria-label="Open">▾</Combobox.Trigger>
</Combobox.Anchor>
Multiple selection. Set multiple; the value becomes an array and the popup
stays open on pick. Wrap the field in Combobox.Anchor and render the selected
chips with Combobox.Value, which exposes { selectedItems, remove }:
<Combobox.Value>
{({ selectedItems, remove }) =>
selectedItems.map((it) => (
<button key={String(it.value)} onClick={() => remove(it.value)}>
{it.label} ×
</button>
))
}
</Combobox.Value>
Creatable. When the query matches nothing, render an option marked create.
Selecting it calls onCreate(inputValue) instead of onValueChange; your
handler persists the new option and selects it:
{
showCreate && (
<Combobox.Option value={query} create>
Create “{query}”
</Combobox.Option>
);
}
Inline autocomplete. Set autocomplete="both" to complete the input to the
first match (Enter or Tab accepts; Backspace dismisses). autocomplete="none"
turns off auto-highlight for a static, non-filtering suggestion list.
Async options. Fetch and filter in an effect and render the results;
override Combobox.Status with a render prop to announce loading instead of the
result count.
Styling
Style through the data-attribute contract: data-state (open / closed) on
the Input, Trigger, and Popup; data-highlighted / data-selected /
data-disabled on Options; data-empty on the Popup when no options are
registered; data-side / data-align on the Positioner and Arrow. The
Positioner is the fixed-positioned wrapper, so size, z-index, and the entry
animation go on the Popup inside it. The demos above use the styles below; copy
a starting point in either flavor:
.docs-cb-input {
box-sizing: border-box;
min-width: 16rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
background: #fff;
color: #18181b;
outline: none;
}
.docs-cb-input:focus {
border-color: #18181b;
}
/* Optional Combobox.Anchor field wrapper (chips / adornments): the wrapper takes
the border and the input inside goes borderless. */
.docs-cb-field {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 16rem;
padding: 0.375rem 0.5rem;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
background: #fff;
cursor: text;
}
.docs-cb-field:focus-within {
border-color: #18181b;
}
.docs-cb-field .docs-cb-input {
flex: 1;
min-width: 5rem;
padding: 0.125rem 0.25rem;
border: none;
}
.docs-cb-trigger {
padding: 0 0.5rem;
border: none;
background: transparent;
color: #71717a;
cursor: pointer;
}
.docs-cb-positioner {
z-index: 50;
}
.docs-cb {
box-sizing: border-box;
min-width: 16rem;
max-height: 16rem;
overflow-y: auto;
padding: 0.25rem;
border: 1px solid #e4e4e7;
border-radius: 0.625rem;
background: #fff;
color: #18181b;
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.25),
0 4px 6px -4px rgb(0 0 0 / 0.25);
outline: none;
opacity: 1;
transform: translateY(0);
transition:
opacity 120ms ease,
transform 120ms ease;
}
@starting-style {
.docs-cb[data-state='open'] {
opacity: 0;
transform: translateY(-4px);
}
}
.docs-cb[data-state='closed'] {
opacity: 0;
transform: translateY(-4px);
}
.docs-cb__option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
border-radius: 0.375rem;
cursor: pointer;
outline: none;
}
.docs-cb__option[data-selected]::after {
content: '✓';
margin-left: auto;
}
.docs-cb__option[data-highlighted] {
background: #18181b;
color: #fafafa;
}
.docs-cb__option[data-disabled] {
color: #a1a1aa;
pointer-events: none;
}
.docs-cb__empty {
padding: 0.5rem 0.6rem;
font-size: 0.875rem;
color: #71717a;
}
@media (prefers-color-scheme: dark) {
.docs-cb-field,
.docs-cb {
border-color: #27272a;
background: #18181b;
color: #fafafa;
}
.docs-cb-field:focus-within {
border-color: #fafafa;
}
.docs-cb__option[data-highlighted] {
background: #fafafa;
color: #18181b;
}
}
@media (prefers-reduced-motion: reduce) {
.docs-cb {
transition: none;
}
}Keyboard
| Key | When | Action |
|---|---|---|
| Printable characters | Input focused | Update query, open the popup, auto-highlight the first match (list/both). |
↓ / ↑ | Input focused, closed | Open the popup. |
↓ / ↑ | Popup open | Move the active option to the next / previous, wrapping at the ends. |
Alt + ↑ | Popup open | Close the popup. |
Home / End | Input focused | Move the text caret (native; the popup is not navigated). |
Enter | Popup open, active set | Commit the active option and close (single) or toggle and stay open (multi). |
Tab | Popup open, both mode | Accept the inline completion (commit the active option). |
Tab | Popup open, other modes | Revert the input to the committed value, close, then move focus. |
Escape | Popup open | Close and revert the display to the typed query (does not reset the value). |
Escape | Input focused, closed | Reset the input to the selected value's label (or empty in multiple mode). |
Backspace | Empty input, multi | Remove the last selected token. |
Focus stays in the input the whole time. The active option is tracked with
aria-activedescendant; it does not receive DOM focus. Arrow navigation wraps
by default; pass loop={false} on Combobox.Root to disable.
Data attributes
| Attribute | On | Values |
|---|---|---|
data-state | Input, Trigger, Popup | open | closed |
data-highlighted | Option | Present while the option is the active descendant |
data-selected | Option | Present when the option is selected |
data-disabled | Option | Present when the option is disabled |
data-side | Positioner, Arrow | top | right | bottom | left (resolved) |
data-align | Positioner | start | center | end (resolved) |
data-empty | Popup | Present when no options are registered |
API reference
Every part accepts a render prop for composition (see
useRender) and forwards unknown props to the
element it renders. Combobox.Option passes { selected, disabled, highlighted } to a render function; Combobox.Status passes { count, open };
Combobox.Value exposes { selectedItems, remove } via a required children
function.
Combobox.Root
Provides value, open state, input value, autocomplete mode, refs, and
positioning to the parts. Generic over the value type (Combobox.Root<Value>);
defaults to string. Renders children and the hidden form field(s) when name
is set.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Value | Value[] | - | Controlled selection. Pair with onValueChange. |
defaultValue | Value | Value[] | - | Initial selection when uncontrolled. |
onValueChange | (value: Value | Value[]) => void | - | Called when the selection changes (not called for create options). |
multiple | boolean | false | Allow more than one selection; value becomes an array; popup stays open on pick. |
open | boolean | - | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | - | Called when the popup requests an open or close. |
inputValue | string | - | Controlled typed query. Pair with onInputChange. |
defaultInputValue | string | '' | Initial query when uncontrolled. |
onInputChange | (value: string) => void | - | Called when the user edits the input (always the typed query, not completion). |
autocomplete | 'none' | 'list' | 'both' | 'list' | Sets aria-autocomplete and governs auto-highlight and inline completion. |
onCreate | (inputValue: string) => void | - | Called when a create option is selected; if absent, create falls back to normal selection. |
itemToString | (value: Value) => string | - | Resolve a label for a value whose option is not currently rendered (SSR, filter). |
name | string | - | Submit the value as a hidden field (one per value in multiple mode). |
disabled | boolean | false | Disable the input and skip the hidden field. |
required | boolean | false | Mark the input aria-required. |
isValueEqual | (a: Value, b: Value) => boolean | Object.is | Compare values for selection matching (use for object values). |
serializeValue | (value: Value) => string | String | Stringify a value for the hidden form field. |
side | 'top'|'right'|'bottom'|'left' | 'bottom' | Preferred side of the input to place the popup. |
align | 'start'|'center'|'end' | 'start' | Alignment along that side. |
offset | number | 8 | Gap in pixels between the input and the popup. |
loop | boolean | true | Wrap arrow navigation at the first / last option. |
openOnFocus | boolean | true | Open the popup when the input gains focus (or is clicked while closed). Set false to opt out. |
children | ComponentChildren | - | The input, trigger, positioner, and status parts. |
Combobox.Input
The editable text input. Owns keyboard navigation, filtering side effects,
inline completion (in both mode), and IME composition handling. Default
element <input type="text"> with role="combobox".
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
...props | JSX.HTMLAttributes<HTMLInputElement> | - | Forwarded; value and onInput are managed by the component. |
Sets role="combobox", aria-autocomplete, aria-expanded, aria-controls,
aria-activedescendant (while open), aria-required (when required), id,
disabled, and data-state.
Combobox.Trigger
An optional chevron button that toggles the popup. Removes itself from the tab
order (tabIndex=-1) so focus stays in the input. Default element
<button type="button">.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
children | ComponentChildren | - | Chevron icon or label. |
...props | JSX.HTMLAttributes<HTMLButtonElement> | - | Forwarded; a passed onClick runs before the toggle. |
Sets aria-controls, aria-expanded, aria-label (defaults to "Open"),
disabled, and data-state.
Combobox.Clear
An optional button that resets the selection and the input, then focuses the
input. Default element <button type="button">.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace the element. |
children | ComponentChildren | - | Button content. |
...props | JSX.HTMLAttributes<HTMLButtonElement> | - | Forwarded; a passed onClick runs before the clear. |
Sets aria-label (defaults to "Clear") and disabled.
Combobox.Anchor
Optional wrapper that becomes the popup's positioning anchor. Wrap the Input
(plus any chips, Trigger, or Clear) in it so the popup aligns to the whole field
instead of the bare input. It is also a dismiss-safe region: pressing its padding
or a chip will not close the popup. When omitted, the popup anchors to the Input.
Default element <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace the element. |
children | ComponentChildren | - | The input and adornments. |
...props | JSX.HTMLAttributes<HTMLElement> | - | Forwarded. |
Combobox.Positioner
The fixed-positioned wrapper that Floating UI drives. Always mounted so options
register their labels even while closed. Default element <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ side: Side; align: Align }> | - | Compose or replace the element. |
children | ComponentChildren | - | The popup (and optional arrow). |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. Style positioning via class. |
Sets position: fixed, resolved data-side / data-align, and hidden
while closed. Promotes to the browser top layer via the Popover API.
Combobox.Popup
The listbox surface. Default element <div> with role="listbox".
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
aria-label | string | - | Accessible name. Defaults to the input's label. |
children | ComponentChildren | - | Options, groups, and Empty. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Sets role="listbox", id, aria-multiselectable (in multiple mode),
data-state, data-empty (when no options are registered), and
aria-labelledby (the input) or aria-label. Owns outside-press dismissal.
Combobox.Empty
Renders only when the popup is open and no options are registered. Use for
"No results" messages. Default element <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace it. |
children | ComponentChildren | - | Message content. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Combobox.Option
A selectable row in the popup. Default element <div> with role="option".
| Prop | Type | Default | Description |
|---|---|---|---|
value | Value | required | The value this option selects. |
create | boolean | false | Route selection to onCreate instead of committing the value. |
render | RenderProp<{ selected: boolean; disabled: boolean; highlighted: boolean }> | - | Compose or replace the element. |
disabled | boolean | false | Skip in navigation and ignore selection. |
children | ComponentChildren | - | Option content; a string child is used as the option's label. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Sets role="option", aria-selected, aria-disabled (when disabled), and
data-selected / data-highlighted / data-disabled.
Combobox.OptionGroup
Wraps related options and labels them with a Combobox.OptionGroupLabel.
Default element <div> with role="group".
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace it. |
children | ComponentChildren | - | A Combobox.OptionGroupLabel and options. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Wires its label to aria-labelledby.
Combobox.OptionGroupLabel
The accessible name for a Combobox.OptionGroup. Presentational, not
selectable. Default element <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace it. |
children | ComponentChildren | - | Label text. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Combobox.Arrow
Optional pointer positioned by Floating UI arrow data. Default element
<div>. Place it on the correct edge with CSS keyed on data-side.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ side: Side }> | - | Compose or replace the element. |
children | ComponentChildren | - | Optional arrow content. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded. |
Sets data-side and position: absolute with the computed offset.
Combobox.Status
A visually-hidden aria-live="polite" region that announces the result count
to screen readers. Default content: "{N} results available" when open and
options exist; "No results" when open and empty; cleared when closed. Default
element <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ count: number; open: boolean }> | - | Override the content. Receives the current option count and open state. |
...props | JSX.HTMLAttributes<HTMLDivElement> | - | Forwarded (excluding children); merged with the visually-hidden style. |
Sets role="status", aria-live="polite", aria-atomic="true", and
visually-hidden styles.
Combobox.Value
A non-visual render-prop accessor for the selected items. Required in multiple mode to render chips; optional in single mode. Renders a fragment (no DOM element of its own).
| Prop | Type | Default | Description |
|---|---|---|---|
children | (state: ComboboxValueState) => ComponentChildren | required | Render function receiving the selected items. |
ComboboxValueState has:
| Field | Type | Description |
|---|---|---|
selectedItems | OptionEntry[] | Selected items in value order, each with { id, value, label }. |
remove | (value: unknown) => void | Toggle an item off (de-select it). |
Primitives
@hono-preact/ui exports the building blocks the parts use, for composing
your own components. Several have their own page with examples:
useListNavigation,
usePosition,
useDismiss,
useRender,
useControllableState, and
mergeRefs.
The matchSubstring helper is also exported from @hono-preact/ui for the
common in-memory filter case:
matchSubstring(label: string, query: string): boolean.
Accessibility
The Combobox follows the WAI-ARIA combobox with listbox pattern. The input is
role="combobox" carrying aria-haspopup="listbox", aria-expanded,
aria-controls, aria-autocomplete, and aria-activedescendant (while
open). The popup is role="listbox" (aria-multiselectable in multiple mode),
named by the input or an explicit aria-label. Options are role="option"
with aria-selected.
- Focus stays in the input at all times. The active option is tracked with
aria-activedescendantand scrolled into view rather than receiving DOM focus. - Focusing the input opens the popup (
openOnFocus, defaulttrue) and selects its text, so the first keystroke starts a fresh search. - The input mirrors the committed value when you are not editing: dismissing without a fresh pick (clicking away or Tab) reverts the text to the selected value's label, so it never shows a dangling, unselected query.
- Arrow keys open the popup from a closed input; Alt+ArrowUp closes it.
- Escape closes but keeps the typed query so you can keep editing; a second Escape reverts the input to the selected value's label.
- Home and End move the text caret (native behavior); they do not navigate the list, per the APG pattern.
Combobox.Statusannounces the result count politely after each filter so screen readers report how many options are available without interrupting ongoing speech.- Set a
nameso the committed value submits in a real form; the hidden field is present before hydration.