hono-preact

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

KeyWhenAction
Printable charactersInput focusedUpdate query, open the popup, auto-highlight the first match (list/both).
/ Input focused, closedOpen the popup.
/ Popup openMove the active option to the next / previous, wrapping at the ends.
Alt + Popup openClose the popup.
Home / EndInput focusedMove the text caret (native; the popup is not navigated).
EnterPopup open, active setCommit the active option and close (single) or toggle and stay open (multi).
TabPopup open, both modeAccept the inline completion (commit the active option).
TabPopup open, other modesRevert the input to the committed value, close, then move focus.
EscapePopup openClose and revert the display to the typed query (does not reset the value).
EscapeInput focused, closedReset the input to the selected value's label (or empty in multiple mode).
BackspaceEmpty input, multiRemove 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

AttributeOnValues
data-stateInput, Trigger, Popupopen | closed
data-highlightedOptionPresent while the option is the active descendant
data-selectedOptionPresent when the option is selected
data-disabledOptionPresent when the option is disabled
data-sidePositioner, Arrowtop | right | bottom | left (resolved)
data-alignPositionerstart | center | end (resolved)
data-emptyPopupPresent 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.

PropTypeDefaultDescription
valueValue | Value[]-Controlled selection. Pair with onValueChange.
defaultValueValue | Value[]-Initial selection when uncontrolled.
onValueChange(value: Value | Value[]) => void-Called when the selection changes (not called for create options).
multiplebooleanfalseAllow more than one selection; value becomes an array; popup stays open on pick.
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the popup requests an open or close.
inputValuestring-Controlled typed query. Pair with onInputChange.
defaultInputValuestring''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).
namestring-Submit the value as a hidden field (one per value in multiple mode).
disabledbooleanfalseDisable the input and skip the hidden field.
requiredbooleanfalseMark the input aria-required.
isValueEqual(a: Value, b: Value) => booleanObject.isCompare values for selection matching (use for object values).
serializeValue(value: Value) => stringStringStringify 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.
offsetnumber8Gap in pixels between the input and the popup.
loopbooleantrueWrap arrow navigation at the first / last option.
openOnFocusbooleantrueOpen the popup when the input gains focus (or is clicked while closed). Set false to opt out.
childrenComponentChildren-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".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
...propsJSX.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">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Chevron icon or label.
...propsJSX.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">.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-Button content.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-The input and adornments.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp<{ side: Side; align: Align }>-Compose or replace the element.
childrenComponentChildren-The popup (and optional arrow).
...propsJSX.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".

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name. Defaults to the input's label.
childrenComponentChildren-Options, groups, and Empty.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-Message content.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded.

Combobox.Option

A selectable row in the popup. Default element <div> with role="option".

PropTypeDefaultDescription
valueValuerequiredThe value this option selects.
createbooleanfalseRoute selection to onCreate instead of committing the value.
renderRenderProp<{ selected: boolean; disabled: boolean; highlighted: boolean }>-Compose or replace the element.
disabledbooleanfalseSkip in navigation and ignore selection.
childrenComponentChildren-Option content; a string child is used as the option's label.
...propsJSX.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".

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-A Combobox.OptionGroupLabel and options.
...propsJSX.HTMLAttributes<HTMLDivElement>-Forwarded.

Wires its label to aria-labelledby.

Combobox.OptionGroupLabel

The accessible name for a Combobox.OptionGroup. Presentational, not selectable. Default element <div>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace it.
childrenComponentChildren-Label text.
...propsJSX.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.

PropTypeDefaultDescription
renderRenderProp<{ side: Side }>-Compose or replace the element.
childrenComponentChildren-Optional arrow content.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp<{ count: number; open: boolean }>-Override the content. Receives the current option count and open state.
...propsJSX.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).

PropTypeDefaultDescription
children(state: ComboboxValueState) => ComponentChildrenrequiredRender function receiving the selected items.

ComboboxValueState has:

FieldTypeDescription
selectedItemsOptionEntry[]Selected items in value order, each with { id, value, label }.
remove(value: unknown) => voidToggle 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-activedescendant and scrolled into view rather than receiving DOM focus.
  • Focusing the input opens the popup (openOnFocus, default true) 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.Status announces the result count politely after each filter so screen readers report how many options are available without interrupting ongoing speech.
  • Set a name so the committed value submits in a real form; the hidden field is present before hydration.