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.
import { Combobox, matchSubstring } from 'hono-preact-ui';
import { useState } from 'preact/hooks';
const FRUITS = ['Apple', 'Banana', 'Cherry', 'Orange', 'Lemon', 'Mango'];
// A single-select combobox in its minimal form: just an Input. It opens on
// focus (the default), anchors the popup to itself, and the consumer filters
// FRUITS by the typed query. Styling is in root.css (.docs-cb*).
export function ComboboxDemo() {
const [query, setQuery] = useState('');
const filtered = FRUITS.filter((f) => matchSubstring(f, query));
return (
<Combobox.Root onInputChange={setQuery}>
<Combobox.Input
class="docs-cb-input"
placeholder="Search fruit…"
aria-label="Fruit"
/>
<Combobox.Status />
<Combobox.Positioner class="docs-cb-positioner">
<Combobox.Popup class="docs-cb" aria-label="Fruit">
{filtered.map((f) => (
<Combobox.Option class="docs-cb__option" key={f} value={f}>
{f}
</Combobox.Option>
))}
<Combobox.Empty class="docs-cb__empty">No results</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Root>
);
}
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.
import { Combobox, matchSubstring } from 'hono-preact-ui';
import { useState } from 'preact/hooks';
const LANGS = ['TypeScript', 'JavaScript', 'Rust', 'Go', 'Python', 'Ruby'];
// Multiple selection shows the two optional parts: Combobox.Anchor wraps the
// chips + input into one field (so the popup aligns to the whole control and
// the field is dismiss-safe), and Combobox.Trigger is a chevron that toggles
// the popup. Picking toggles and keeps the popup open; Combobox.Value renders
// the chips; Backspace on an empty input removes the last token.
export function ComboboxMultiDemo() {
const [query, setQuery] = useState('');
const filtered = LANGS.filter((l) => matchSubstring(l, query));
return (
<Combobox.Root multiple onInputChange={setQuery}>
<Combobox.Anchor class="docs-cb-field">
<Combobox.Value>
{({ selectedItems, remove }) =>
selectedItems.map((it) => (
<span class="docs-cb-chip" key={String(it.value)}>
{it.label}
<button
type="button"
class="docs-cb-chip__remove"
onClick={() => remove(it.value)}
aria-label={`Remove ${it.label}`}
>
×
</button>
</span>
))
}
</Combobox.Value>
<Combobox.Input
class="docs-cb-input"
placeholder="Add language…"
aria-label="Languages"
/>
<Combobox.Trigger class="docs-cb-trigger" aria-label="Open">
▾
</Combobox.Trigger>
</Combobox.Anchor>
<Combobox.Status />
<Combobox.Positioner class="docs-cb-positioner">
<Combobox.Popup class="docs-cb" aria-label="Languages">
{filtered.map((l) => (
<Combobox.Option class="docs-cb__option" key={l} value={l}>
{l}
</Combobox.Option>
))}
<Combobox.Empty class="docs-cb__empty">No results</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Root>
);
}
Creatable: when nothing matches, a "Create …" option appears and adds the new
value through onCreate.
import { Combobox, matchSubstring } from 'hono-preact-ui';
import { useState } from 'preact/hooks';
// Creatable: when the query matches no existing option, a `create` option is
// rendered. Selecting it calls `onCreate` (which persists and selects the new
// value) instead of firing `onValueChange`, so the select-from-list invariant
// holds. Minimal form: just an Input (no field wrapper or trigger needed).
export function ComboboxCreatableDemo() {
const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']);
const [value, setValue] = useState('');
const [query, setQuery] = useState('');
const filtered = options.filter((o) => matchSubstring(o, query));
const showCreate =
query !== '' &&
!options.some((o) => o.toLowerCase() === query.toLowerCase());
return (
<Combobox.Root
value={value}
onValueChange={(v) => setValue(Array.isArray(v) ? (v[0] ?? '') : v)}
onInputChange={setQuery}
onCreate={(label) => {
setOptions((prev) => [...prev, label]);
setValue(label);
}}
>
<Combobox.Input
class="docs-cb-input"
placeholder="Pick or create…"
aria-label="Tag"
/>
<Combobox.Status />
<Combobox.Positioner class="docs-cb-positioner">
<Combobox.Popup class="docs-cb" aria-label="Tag">
{filtered.map((o) => (
<Combobox.Option class="docs-cb__option" key={o} value={o}>
{o}
</Combobox.Option>
))}
{showCreate && (
<Combobox.Option
class="docs-cb__option docs-cb__create"
value={query}
create
>
Create “{query}”
</Combobox.Option>
)}
<Combobox.Empty class="docs-cb__empty">
Type to add a tag
</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Root>
);
}
With autocomplete="both", the input inline-completes to the first match; Enter
or Tab accepts it, Backspace dismisses it and keeps typing.
import { Combobox, matchSubstring } from 'hono-preact-ui';
import { useState } from 'preact/hooks';
const CITIES = [
'Amsterdam',
'Barcelona',
'Copenhagen',
'Dublin',
'Edinburgh',
'Florence',
'Geneva',
'Helsinki',
];
// Inline autocomplete (autocomplete="both"): the input displays the first
// matching option's label as a selected suffix after the typed text. Enter or
// Tab accepts it; Backspace or ArrowLeft dismisses it and keeps the query.
// Minimal form: just an Input.
export function ComboboxInlineDemo() {
const [query, setQuery] = useState('');
const filtered = CITIES.filter((c) => matchSubstring(c, query));
return (
<Combobox.Root autocomplete="both" onInputChange={setQuery}>
<Combobox.Input
class="docs-cb-input"
placeholder="Type a city…"
aria-label="City"
/>
<Combobox.Status />
<Combobox.Positioner class="docs-cb-positioner">
<Combobox.Popup class="docs-cb" aria-label="City">
{filtered.map((c) => (
<Combobox.Option class="docs-cb__option" key={c} value={c}>
{c}
</Combobox.Option>
))}
<Combobox.Empty class="docs-cb__empty">No results</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Root>
);
}
Usage#
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 }. It
renders a <span> and accepts standard HTML attributes (like class) and a
render prop for full control:
<Combobox.Value class="chip-list">
{({ 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.
Form reset#
Inside a <form>, the component resets to its defaultValue when the form is
reset (a reset button, or Form's reset), the same way a native field resets
to its default. The input text returns to defaultInputValue. A reset whose
event is preventDefaulted is ignored.
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;
}
}{
/* Minimal: the Input is the field box. */
}
<Combobox.Input className="min-w-64 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-50" />;
{
/* For chips or a chevron, wrap in Combobox.Anchor (the border moves to the
wrapper and the input goes borderless):
<Combobox.Anchor className="inline-flex min-w-64 flex-wrap items-center gap-1 rounded-lg border border-zinc-200 bg-white px-2 py-1.5 focus-within:border-zinc-900 dark:border-zinc-800 dark:bg-zinc-900">
<Combobox.Input className="min-w-20 flex-1 border-0 bg-transparent px-1 text-sm outline-none" />
<Combobox.Trigger className="px-2 text-zinc-400" />
</Combobox.Anchor> */
}
<Combobox.Positioner className="z-50">
<Combobox.Popup className="min-w-64 max-h-64 overflow-y-auto rounded-[0.625rem] border border-zinc-200 bg-white p-1 text-zinc-900 shadow-xl outline-none opacity-100 translate-y-0 transition-[opacity,translate] duration-[120ms] ease-out starting:opacity-0 starting:-translate-y-1 data-[state=closed]:opacity-0 data-[state=closed]:-translate-y-1 motion-reduce:transition-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
<Combobox.Option className="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none data-[highlighted]:bg-zinc-900 data-[highlighted]:text-zinc-50 data-[selected]:after:content-['✓'] data-[selected]:after:ml-auto data-[disabled]:pointer-events-none data-[disabled]:text-zinc-400 dark:data-[highlighted]:bg-zinc-50 dark:data-[highlighted]:text-zinc-900">
Apple
</Combobox.Option>
<Combobox.Empty className="px-2.5 py-2 text-sm text-zinc-500">
No results
</Combobox.Empty>
</Combobox.Popup>
</Combobox.Positioner>;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
renderElement) 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 children function or
a render prop.
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#
Renders selected items. Required in multiple mode to render chips; optional in
single mode. Renders a <span> wrapper and accepts standard HTML attributes
(like class for styling the chip container). Generic over the root's value
type (Combobox.Value<Value>).
| Prop | Type | Default | Description |
|---|---|---|---|
children | (state: ComboboxValueState<Value>) => ComponentChildren | - | Render function receiving the selected items. Optional when render is provided. |
render | (props, state: ComboboxValueState<Value>) => VNode | - | Replace the default <span> entirely; receives forwarded props and state. |
...props | JSX.HTMLAttributes<HTMLSpanElement> | - | Forwarded to the <span> wrapper (excluding children); e.g. class for chip-list styling. |
ComboboxValueState<Value> has:
| Field | Type | Description |
|---|---|---|
selectedItems | OptionEntry<Value>[] | Selected items in value order, each with { id, value, label }. |
remove | (value: Value) => 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,
renderElement,
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.