hope-uiearly preview

Combobox

A text field that narrows a list of options as you type, and a floating listbox to choose from — the ARIA 1.2 combobox pattern with an editable focus owner: the same kernel as Select, with the trigger swapped for an input and a collator-backed filter on top.

Import

import { Combobox } from "@hope-ui/components/combobox";

Combobox is a compound component — a namespace object whose parts you compose yourself. See the Anatomy below for every part and how they nest.

Usage

A Combobox is a Select with its focus owner swapped and a filter added. The role="combobox" moves off a <button> and onto an <input>, so the control is something you type into, and Combobox.Root narrows items as you do.

Combobox.Root takes the whole option set as items and owns the state (selection, the text, the filter, open/close, keyboard, positioning, ARIA); it renders no element of its own. Combobox.Control is the bordered shell, holding the Combobox.Input that owns focus, an optional Combobox.Clear, and the Combobox.Trigger chevron. Everything from Combobox.Portal down is the popup: Combobox.List takes a render callback invoked once per surviving entry, with Combobox.Empty and Combobox.Status beside it in the card.

<Combobox.Root
  items={fruits}
  itemToValue={itemToValue}
  itemToLabel={itemToLabel}
>
  <Combobox.Control>
    <Combobox.Input aria-label="Choose a fruit" placeholder="Search fruit…" />
    <Combobox.Clear />
    <Combobox.Trigger>
      <Combobox.Icon />
    </Combobox.Trigger>
  </Combobox.Control>
  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Content>
        <Combobox.List>
          {(fruit: Fruit) => (
            <Combobox.Item item={fruit}>
              <Combobox.ItemText>{fruit.name}</Combobox.ItemText>
              <Combobox.ItemIndicator />
            </Combobox.Item>
          )}
        </Combobox.List>
        <Combobox.Empty>No fruit matches that.</Combobox.Empty>
        <Combobox.Status />
      </Combobox.Content>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

Options are data

items is the whole option set, held on Combobox.Root rather than in the popup — and nothing renders until the popup opens. A page of ten closed Comboboxes mounts zero option lists, while each one still knows how many options it has and what the filter would leave.

Your items stay your own objects: every question the Combobox asks about one is an accessor function, never a required key name.

  • itemToValue — the selection identity, compared for equality. Defaults to String.
  • itemToLabel — the display text, and what the built-in filters match against. Defaults to itemToValue.
  • isItemDisabled — whether the row is dimmed and skipped. Disable the whole control with disabled on Combobox.Root.
<Combobox.Root
  items={fruits}
  itemToValue={(fruit) => String(fruit.id)}
  itemToLabel={(fruit) => fruit.name}
  isItemDisabled={(fruit) => fruit.outOfSeason}
/>

Anatomy

The nesting below the portal is fixed, and it is Popover’s spine: Portal lifts the layer out of the page’s stacking and overflow contexts, Positioner is the element the positioning engine measures and moves, and Content is the card. Splitting the last two is what keeps them from fighting: the card’s enter/exit translate would otherwise collide with the translate() the engine writes onto the positioner.

Content and List stay distinct for a different reason, and on a Combobox it is the one that matters most: a role="listbox" may only contain options and groups, so Combobox.Empty and Combobox.Status live in the card beside the list, never inside it. Put either in there and a screen reader counts it as an option.

Combobox.Root
├── Combobox.Control
│ ├── Combobox.Input
│ ├── Combobox.Clear optional
│ └── Combobox.Trigger
│ └── Combobox.Icon
└── Combobox.Portal
└── Combobox.Positioner
└── Combobox.Content
├── Combobox.List
│ ├── Combobox.Item
│ │ ├── Combobox.ItemText
│ │ └── Combobox.ItemIndicator
│ ├── Combobox.Group with groupToItems
│ │ ├── Combobox.GroupLabel
│ │ └── Combobox.Item
│ └── Combobox.Separator optional
├── Combobox.Empty beside the list
└── Combobox.Status beside the list
PartElementDescription
Combobox.RootNoneState owner: the items, the filter derived from the text, the selection, open state, keyboard, ids, positioning, and the resolved recipe. Renders no host element — a context provider and nothing else.
Combobox.ControldivThe bordered shell around the input and the two gutter buttons — the part Select does not have. It exists because the focus ring has to move outward: Select’s box is its focus owner, this one holds it, so the recipe draws the ring with focus-within. It also registers itself as the popup’s positioning anchor and as the outer edge of “not outside” for dismissal.
Combobox.Inputinput[role=combobox]The text field, and the widget’s focus owner: it keeps DOM focus open or closed, owns the entire keymap, and points aria-activedescendant at the highlighted option. Wires aria-autocomplete / aria-expanded / aria-controls, and switches off the browser’s own autocomplete so it cannot overlay the popup with a second list.
Combobox.ClearbuttonEmpties the field — the text and the selection both — and hands focus back to the input. tabindex="-1", with a localized aria-label. Renders nothing while there is nothing to clear, unless alwaysVisible is set.
Combobox.TriggerbuttonThe chevron: a pointer affordance for opening the popup, and nothing else. It carries no role="combobox", no keymap and no aria-activedescendant — a second combobox in one tree would give a screen reader two fields. tabindex="-1", with a localized aria-label.
Combobox.IconspanThe chevron glyph. Decorative and aria-hidden — it says nothing the input’s aria-expanded does not. Renders the theme’s chevronIcon unless given children.
Combobox.PortalNonePortals the Positioner to document.body (client-only), so no ancestor’s overflow or transform can clip the popup.
Combobox.PositionerdivThe measured layer. Its position / left / top / transform are written inline by the positioning engine; it reports the resolved geometry as data-side / data-align and publishes --anchor-width and --available-height.
Combobox.ContentdivThe card, and the behavior hub: dismissal, plus the assistive-tech hiding and scroll lock when modal is on (it is off by default here). Carries no role: the card is chrome, and it holds more than the list.
Combobox.Listdiv[role=listbox]The options’ container and the scroll container. Iterates the surviving entries itself: its child is a render callback invoked once per entry the filter kept.
Combobox.EmptydivThe “nothing matched” message, rendered only while the filtered set is empty. Not a live region — Status is, and two regions would announce the same fact twice. This is what a sighted reader sees.
Combobox.Statusdiv[role=status]The result count, shown and announced. Filtering is the one thing a combobox does that a screen reader cannot otherwise observe: focus does not move, the text is the user’s own, and the list silently gets shorter.
Combobox.Itemdiv[role=option]One option. Owns aria-selected, the active/selected/disabled data attributes, and the pointer handlers. Give it item — the entry it renders; it resolves its own position from that, so it can sit at any depth.
Combobox.ItemTextspanThe option’s label — a truncating box, so a long label ellipsizes instead of pushing the check out of its gutter.
Combobox.ItemIndicatorspanThe selection check, shown only while its row is selected. Renders the theme’s checkIcon unless given children. In multiple mode it is the only thing reporting what is chosen.
Combobox.Groupdiv[role=group]A labeled section, named by its GroupLabel via aria-labelledby. Needs groupToItems on Root.
Combobox.GroupLabeldivNames its Group. Self-registers its id as the group’s aria-labelledby.
Combobox.Separatordiv[role=presentation]A decorative hairline between sections (aria-hidden — never reported as an option).

Filtering

filter decides how items narrows as the user types — and it is the only thing the popup ever shows. Combobox.Root derives one array from your items and the current text, and everything downstream falls out of it: the rows, the count Combobox.Status announces, the emptiness Combobox.Empty reports, and the set the arrow keys traverse.

  • "contains" (the default): the label contains the query anywhere.
  • "startsWith": the label begins with it.
  • a function: (item, query) => boolean, receiving the raw item, so it can match on fields the label never shows.
  • false: no filtering at all. See Async search.
filter="contains"
filter="startsWith"
filter={(fruit, query) => …}
<Combobox.Root filter="contains">…</Combobox.Root>
<Combobox.Root filter="startsWith">…</Combobox.Root>
<Combobox.Root filter={(fruit: Fruit, query) => String(fruit.id) === query}>

</Combobox.Root>

The two built-ins are collator-backed ({ usage: "search", sensitivity: "base" }), so they fold case and diacritics together: cafe matches Café au lait melon and acai matches Açaí. toLowerCase() folds only case and matches neither — it fails silently for every accented language. The same collator backs Select’s type-ahead, so the two agree on what “matches” means.

Opening a committed field with the chevron shows everything, not the matches for the label sitting in it — otherwise picking “Apple” would leave you re-opening onto a one-row list. Only typing narrows; committing, reverting and choosing an option all put the full set back.

Nothing matched

A query that matches nothing keeps the popup open, so Combobox.Empty can say so. That is why Combobox.Root defaults allowsEmptyCollection to true where the underlying kernel defaults it to false — closing would take the part whose whole job is reporting the dead end off screen with it.

<Combobox.Content>
  <Combobox.List>{/* … */}</Combobox.List>
  <Combobox.Empty>No fruit matches that.</Combobox.Empty>
  <Combobox.Status />
</Combobox.Content>

Combobox.Status is the other half, and it is visible on purpose: the count helps a sighted reader too, and a visually-hidden live region is one display: none away from announcing nothing at all. Pass a callback to reword or localize it.

<Combobox.Status>{(count) => `${count} fruits`}</Combobox.Status>

Set filter={false} and nothing is filtered: items is passed through untouched, keeping its array identity, so the rows on screen are exactly the ones you handed back. The search moves to onInputValueChange, which fires on every keystroke.

8 result(s) from the “server”
const [results, setResults] = createSignal<Fruit[]>([]);
 
<Combobox.Root
  items={results()}
  filter={false}
  onInputValueChange={async (query) => setResults(await searchFruit(query))}
>
  {/* … */}
</Combobox.Root>;

Debouncing and cancellation are yours to own — this is the shape that hands you the keystrokes, not one that hides them.

Controlled value

A Combobox has two values, controllable independently.

  • The selectionvalue + onChange, with defaultValue for uncontrolled use. It is a scalar in single mode: a single Combobox hands back the item itself, never [item], and null is how “nothing selected” is spelled. Whether it is a scalar or an array is inferred from selectionMode, so nothing is cast at the call site.
  • The textinputValue + onInputValueChange, with defaultInputValue for uncontrolled use. It defaults to the initially-selected item’s label in single mode, so defaultValue={apple} shows “Apple” from the first frame.
selection: "Cherry" · text: "Cherry"
const [fruit, setFruit] = createSignal<Fruit | null>(null);
const [text, setText] = createSignal("");
 
<Combobox.Root
  items={fruits}
  value={fruit()}
  onChange={setFruit}
  inputValue={text()}
  onInputValueChange={setText}
>
  {/* … */}
</Combobox.Root>;

onInputValueChange fires on typing, on each IME composition update, and on every commit and revert — so it is the whole story of what the field says, not only what was typed into it.

The open state works the same way: defaultOpen for uncontrolled, or open + onOpenChange to own it. Every path in and out — a keystroke, the chevron, Escape, an outside press, picking an option — routes through onOpenChange first.

Custom values

Text that matches no option has to end somewhere, and allowsCustomValue picks the ending.

  • false (the default): Enter, Tab or blur reverts the field to the current selection’s label. That is what stops a picker showing one value while reporting another.
  • true: the text stands on its own. It survives the commit, and the selection is left untouched.
allowsCustomValue
text:
default (reverts)
text:
<Combobox.Root allowsCustomValue items={fruits} onInputValueChange={setQuery}>
  {/* … */}
</Combobox.Root>

Either way, committing with an option highlighted selects it — allowsCustomValue only governs what happens when nothing is highlighted.

Selection modes

selectionMode controls how many options can be chosen at once — the same vocabulary Listbox and Select use, never a multiple boolean:

  • single (default): one option; choosing another replaces it, the field takes its label, and the popup closes.
  • multiple: a set; the popup stays open while you tick rows, because shouldCloseOnSelect defaults to selectionMode !== "multiple".
  • none: nothing is ever selected; the arrows still move the highlight. A command palette rather than a field.

The mode also types the value: scalar (V | null) in single, an array (V[]) in multiple.

In multiple, the input stays the query rather than becoming a joined list of labels — there is no one label to show — and it is cleared after each pick so the next search starts fresh. That makes the checks in the list the only report of what is chosen, which is why Combobox.ItemIndicator is not optional in a multi-select tree.

picked: Apple, Date
<Combobox.Root
  selectionMode="multiple"
  items={FRUITS}
  value={fruits()}
  onChange={setFruits}
>
  {/* … */}
</Combobox.Root>

Groups and separators

Set groupToItems and items becomes your group entries: it flattens them into navigation order, which is the only thing the Combobox needs from a group. Your callback then goes one level up — it is invoked per group, and you iterate that group’s own items with a plain <For>.

The group’s name never reaches the Combobox: you render it from your own key, which is why there is no groupToLabel and no { label, items } shape to conform to. Keyboard navigation flows across groups as one list — the arrows skip the labels and the hairline.

Filtering runs inside each group and drops the ones left empty, so a heading never survives its last row.

<Combobox.Root items={baskets} groupToItems={(basket) => basket.fruits}>
  {/* … */}
  <Combobox.List>
    {(basket: Basket, index, fruits: Accessor<Fruit[]>) => (
      <>
        <Show when={index() > 0}>
          <Combobox.Separator />
        </Show>
        <Combobox.Group>
          <Combobox.GroupLabel>{basket.kind}</Combobox.GroupLabel>
          <For each={fruits()}>{(fruit) => <FruitItem fruit={fruit} />}</For>
        </Combobox.Group>
      </>
    )}
  </Combobox.List>
</Combobox.Root>

It is an accessor rather than an array for the same reason index is. <For> diffs by reference, so a group that survives a narrowing query is reused and this callback never runs again — a captured array would be the previous query’s. Reading the accessor in the callback body rather than inside a tracking scope raises a [STRICT_READ_UNTRACKED] diagnostic saying exactly that.

This is the one API difference from Select.List, whose callback has no third argument because nothing ever narrows its groups.

What opens the popup

menuTrigger decides what opens the popup on its own — and only that. The chevron and the arrow keys open it in all three modes, which is what keeps "manual" usable from the keyboard.

  • "input" (default): the first keystroke opens it, with the top match highlighted so Enter commits it. A search field.
  • "focus": focusing the field opens it. Suits a short, browsable list.
  • "manual": only the chevron or an arrow key opens it. Suits a field whose text matters more than its list.
menuTrigger="input"
menuTrigger="focus"
menuTrigger="manual"
<Combobox.Root menuTrigger="focus">…</Combobox.Root>

Sizes

The size prop scales density across sm, md (the default), and lg — and it scales the control and the popup together, because the two have to agree: a lg field opening an md list is the one way this can look broken. There is no color axis; a Combobox is a neutral control over a neutral overlay, and its only accents are the transient highlight and the persistent selection.

sm
md
lg
<Combobox.Root size="sm">…</Combobox.Root>
<Combobox.Root size="md">…</Combobox.Root>
<Combobox.Root size="lg">…</Combobox.Root>

Disabled

disabled on Combobox.Root turns the whole control off — the shell dims, the input takes no text and no focus, and nothing opens. isItemDisabled turns a single row off: it stays in the list and keeps aria-disabled, so it is still discoverable, but navigation skips it (skipDisabled, on by default) and it can never be selected.

disabled
isItemDisabled
<Combobox.Root disabled>…</Combobox.Root>
<Combobox.Root isItemDisabled={(fruit) => fruit.outOfSeason}>…</Combobox.Root>

Long option lists

The popup caps its height at the space measured to the viewport edge (--available-height) and Combobox.List scrolls inside the card, so the rounded corners, the border and the pinned status line stay still while the rows move. Arrowing past the fold scrolls the highlighted row into view — in this pattern nothing else would, since no option ever takes DOM focus.

Two things Select offers are deliberately absent here, and both are compile errors rather than silent breakage:

Not availableWhy
estimateSize / overscanVirtualization. A windowed row is recycled — it stays mounted while its position changes — so Combobox.Item’s item→index lookup cannot work, and every row would silently register nothing. For a picker over tens of thousands of rows, reach for a Listbox.
name / form / requiredNative form submission. Select renders a real, clipped <select> holding one <option> per item; a Combobox’s option set is the filtered one, so the same field would drop options as the user typed and submit whatever the query happened to leave. Read the selection from onChange instead.

The popup

side picks which side of the control the popup prefers (default "bottom") and align skids it along that side’s cross axis (default "center") — but both are a preference, not a promise: near a viewport edge the card flips to the opposite side and slides to stay in view, and what the parts report as data-side / data-align is where it actually landed. The component’s own defaults are a sideOffset of 4 and a collisionPadding of 8.

The width is not a decision you make: the popup always matches the control, via the --anchor-width the positioner publishes and the recipe spends. Override the positioner slot if you need something else.

<Combobox.Root side="top" sideOffset={8} collisionPadding={16} flip={false}>

</Combobox.Root>

Combobox.Portal renders the layer at the end of <body>, so a Combobox inside a scroll container, a table cell, or a transformed ancestor is never clipped by it — while the position stays glued to the control as the container scrolls (autoUpdate, on by default). Pass mount to portal somewhere else, and trackAnchorMotion for a control that moves under a CSS transform.

An open Combobox is not modal, the opposite of Select’s default. Hiding the rest of the page from assistive tech and locking body scroll is right for a picker the user has committed to, and wrong for a search field they are still typing into. Pass modal to turn it on.

Dismissal

An open Combobox light-dismisses three ways, each independently switchable: Escape, a pointer-down outside the popup, and focus landing outside it. All three are on by default, and focus stays in the input — where, in this pattern, it never left.

Escape does one more thing here than it does on a Select: it reverts the field to the last committed text before closing.

<Combobox.Root closeOnEscape={false} closeOnInteractOutside={false}>

</Combobox.Root>

A Combobox nested inside a Dialog or a Popover takes the first Escape on its own and leaves the layer below it standing; set bubbles if you want one keystroke to take the whole chain.

One Escape closes this popup and everything under it
<Combobox.Root bubbles={{ escapeKey: true }}>…</Combobox.Root>

Keyboard interactions

Combobox implements the WAI-ARIA 1.2 combobox keyboard pattern. Every key is handled on the input, because there is nowhere else for it to live: no option is ever focused, and neither gutter button is in the tab order, so Tab crosses the whole widget in one press.

KeyClosedOpen
↓ / Alt + ↓Opens the popup on the first option.Moves the highlight to the next option (disabled rows are skipped).
Opens the popup on the last option.Moves the highlight to the previous option.
Alt + ↑Opens the popup on the last option.Closes the popup.
EnterNot consumed — a Combobox in a form still submits it.Commits the highlighted option and closes. With nothing highlighted, reverts unless allowsCustomValue.
TabMoves to the next control.Commits, then lets focus leave. The gutter buttons are not tab stops.
EscapeNot consumed — a Combobox inside a Dialog never swallows the Dialog’s Escape.Reverts the text to the last committed value and closes. Focus stays in the input.
Home / EndMoves the caret, as in any text field.Moves the highlight to the first / last option.
PageUp / PageDownNot consumed.Moves the highlight by a page.
← / →Moves the caret.Drops the highlight, then moves the caret — editing text again means Enter must not commit a stale row.
printable / SpaceTypes. Opens the popup when menuTrigger is input.Types, and re-filters the list.

Polymorphism

Render a part as a different element or component with the render prop — a function that receives the part’s computed props and spreads them onto your element. Every part except Combobox.Root and Combobox.Portal accepts it. There is no as prop; render is the single polymorphism API.

The chevron, as a div
<Combobox.Trigger
  nativeButton={false}
  render={(props) => <div {...props} class="grid size-8 place-items-center" />}
>
  <Combobox.Icon />
</Combobox.Trigger>

Setting class after the spread is what replaces the recipe’s slot chrome rather than merging into it. Everything else rides through the spread untouched — the popup ARIA, the localized aria-label, the tabindex="-1" and the focus-preserving pointerdown.

If a re-targeted Combobox.Trigger or Combobox.Clear is not a real <button>, pass nativeButton={false} alongside it, so the disabled behavior switches to tabIndex / aria-disabled and keyboard activation is synthesized.

Theming

Combobox’s look comes from the active preset’s recipe. It is two surfaces in one recipe — a form control and a floating card — with a single size axis and no color axis. Override styling at three levels, applied in order (later wins a Tailwind conflict): recipe base → preset slotClasses → instance slotClasses / part class.

Part slots

Every styled part carries a data-slot attribute you can target, and each is addressable by name through slotClasses. There is no root slot (Combobox.Root renders no element) and no portal slot, for the same reason.

Slotdata-slotDescription
controlcombobox-controlThe bordered shell: the raised surface, the hairline, the density — and the focus ring, drawn with focus-within, because the element that takes focus is inside it.
inputcombobox-inputThe text field. Deliberately chrome-free (no border, no background, no outline): a second box here would fight the control’s. The empty state is the native placeholder: pseudo-element, so no slot and no data-* of its own.
clearcombobox-clearThe reset button’s hit area and its wash. No focus ring — it is not a tab stop.
triggercombobox-triggerThe chevron’s hit area, and nothing else — the control draws the chrome.
iconcombobox-iconThe chevron’s box.
positionercombobox-positionerThe measured layer, where --anchor-width and --available-height are published. Stacking and width only — never anything positional, which would fight the inline style the engine writes.
contentcombobox-contentThe popup card, its elevation, and the enter/exit transition.
listcombobox-listThe scroll container inside the card.
emptycombobox-emptyThe no-results message, in the card beside the list.
statuscombobox-statusThe pinned result count. It does not shrink when the list does, so the count stays visible while the rows scroll.
itemcombobox-itemAn option row — carries the highlight and the selected/disabled state.
itemTextcombobox-item-textThe option’s truncating label box.
itemIndicatorcombobox-item-indicatorThe selection check’s placement in the trailing gutter.
groupcombobox-groupA labeled section wrapper.
groupLabelcombobox-group-labelThe small, muted section label.
separatorcombobox-separatorThe hairline divider between sections.

The positioner and the content also report the resolved geometry as data-side / data-align, and their transition state as data-presence — which is how the preset animates the card in from the control’s direction. The highlight is data-active on a row, never a hover: state: the keyboard and the pointer share one active option, so a hover background would paint a second highlight the moment the cursor lagged a frame behind. It matters more here than anywhere, since focus never leaves the input and data-active is the only signal there is.

Targeting the resolved side
[data-slot="combobox-content"][data-side="top"] { /* … */ }

Overriding one Combobox

Set slotClasses on Combobox.Root to reach any slot from one place, or put class on an individual part. Use literal class strings so your Tailwind build can see them.

Every slot, from Root
<Combobox.Root
  slotClasses={{ content: "shadow-2xl", item: "rounded-md font-mono" }}
>
  {/* … */}
</Combobox.Root>
One part
<Combobox.Input class="font-mono" />

The three glyphs are per-instance overridable as plain children — Combobox.Icon, Combobox.ItemIndicator and Combobox.Clear each render the theme’s default only when given none.

<Combobox.Icon>
  <CaretIcon />
</Combobox.Icon>

App-wide defaults and overrides

Set the default size, all three glyphs, and global part classes for every Combobox through the theme with definePreset, then pass the derived preset to ThemeProvider. defaultProps resolve at instance ?? preset ?? builtin precedence, so an explicit prop on a single Combobox always wins.

theme.tsx
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
 
export const myPreset = definePreset(hope, {
  components: {
    combobox: {
      defaultProps: {
        size: "sm",
        chevronIcon: () => <CaretIcon />,
        checkIcon: () => <TickIcon />,
        clearIcon: () => <CrossIcon />,
      },
      slotClasses: { content: "rounded-none" },
    },
  },
});

All three glyphs are factories (() => JSX.Element), never a built element: a preset value is one object shared by every instance, and an already-built Solid node would move rather than be reused.

size is the recipe’s only variant, so it is the one styling prop defaultable app-wide. filter, menuTrigger, selectionMode, modal and the dismissal props change behavior rather than looks and stay on the instance.

API

Combobox.Root

Combobox.Root<V, M, G> is generic in your item type V, its selection mode M, and the shape of an items entry G (the same as V for a flat list, the group’s shape with groupToItems set). It renders no host element, so it takes no native attributes — only the props below.

PropDefaultType
items
readonly G[]Required. The whole option set, in navigation order — your items for a flat list, or the group entries when groupToItems is set. Held here rather than in the popup, so a closed Combobox still knows what it has without mounting a row.
filter"contains"
"contains" | "startsWith" | ((item: V, query: string) => boolean) | falseHow items narrows as the user types. The two built-ins are collator-backed, so cafe matches Café. false disables filtering entirely — the async-search shape, where you fetch on onInputValueChange and hand back the results as items.
inputValue
stringControlled text in the input. Omit the prop entirely for uncontrolled use.
defaultInputValuethe selected item’s label
stringInitial text when uncontrolled. Defaults to the initially-selected item’s label in single mode, and to an empty string otherwise.
onInputValueChange
(value: string) => voidCalled on every text change: typing, each IME composition update, and every commit and revert.
allowsCustomValuefalse
booleanWhether text matching no option survives a commit. Off, Enter / Tab / blur revert the field to the current selection’s label. On, the text stands alone and the selection is left untouched.
menuTrigger"input"
"input" | "focus" | "manual"What opens the popup on its own — typing, focusing, or nothing. All three leave the arrow keys and the chevron working.
groupToItems
(group: G) => readonly V[]Maps a group entry to its own items, flattening items into navigation order. Setting it switches Combobox.List’s callback from per-item to per-group, and adds the filtered-items third argument.
itemToValueString
(item: V) => stringMaps an item to its selection identity, compared for equality. Must be unique per item. It is not the row’s DOM id, which is generated.
itemToLabelitemToValue
(item: V) => stringMaps an item to its display text — what the field shows once committed, and what the built-in filters match against. Read from the data, so it works for a row that has not mounted.
isItemDisabledfalse
(item: V) => booleanWhether an item is disabled — dimmed, and skipped by navigation unless skipDisabled is off. It can never be selected.
isItemEqualToValueby itemToValue
(a: V, b: V) => booleanFull override of value equality. Defaults to comparing itemToValue(a) === itemToValue(b).
selectionMode"single"
"single" | "multiple" | "none"How many options can be selected at once. It also types value / defaultValue / onChange as a scalar or an array.
value
V | null (single) · V[] (multiple)Controlled selection. null is a controlled “nothing selected”; omit the prop entirely for uncontrolled use.
defaultValue
V | null (single) · V[] (multiple)Initial selection when uncontrolled. In single mode it also seeds the input’s text.
onChange
(value: V | null | V[]) => voidCalled on every selection change, in the same shape as value.
open
booleanControlled open state. Leave unset for uncontrolled (defaults to defaultOpen).
defaultOpenfalse
booleanInitial open state when uncontrolled.
onOpenChange
(open: boolean) => voidCalled on every open/close request — a keystroke, the chevron, Escape, an outside press, and choosing an option.
allowsEmptyCollectiontrue
booleanWhether the popup may open with no options in it. True here, unlike Select: an empty result is the normal outcome of typing, and closing would hide Combobox.Empty.
shouldCloseOnSelectselectionMode !== "multiple"
booleanWhether choosing an option closes the popup. Applied to every path that selects — Enter, Tab, and a click.
modalfalse
booleanWhether an open popup hides the rest of the page from assistive tech and locks body scroll. False here, unlike Select: marking the page inert is wrong for a field the user is still typing into.
closeOnEscapetrue
booleanWhether Escape reverts the text and closes the popup.
closeOnInteractOutsidetrue
booleanWhether a pointer-down outside the popup closes it.
closeOnFocusOutsidetrue
booleanWhether focus landing outside the popup closes it.
bubblesfalse
boolean | { escapeKey?: boolean; outsidePress?: boolean }Whether a dismissal consumed by a layer opened above this popup also closes it. Off for both channels: the topmost layer alone dismisses.
side"bottom"
"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end"Preferred side of the control. The two inline values resolve against the layer’s reading direction.
align"center"
"start" | "center" | "end"Alignment along the side’s cross axis.
sideOffset4
numberDistance from the control, in px — the small gap a picker keeps from its field.
alignOffset0
numberSkid along the alignment axis, in px.
fliptrue
booleanFlip to the opposite side when the preferred one would overflow.
shifttrue
booleanSlide along the alignment axis to stay in view.
collisionPadding8
number | Partial<Record<Side, number>>Gutter kept between the popup and the collision boundary.
collisionBoundary"clippingAncestors"
Element | Element[] | Rect | 'clippingAncestors'What the popup must stay inside.
strategy"absolute"
"absolute" | "fixed"CSS position used for the positioner.
autoUpdatetrue
booleanKeep the position current through scroll and resize.
trackAnchorMotionfalse
booleanRe-measure every animation frame — for a control that moves under a transform.
disabledfalse
booleanDisable the whole control — the shell dims and the input takes no text or focus.
idgenerated
stringBase id for the widget’s generated ids.
skipDisabledtrue
booleanWhether navigation skips disabled items.
wrapfalse
booleanWhether arrow navigation wraps past the ends of the list.
orientation"vertical"
"vertical" | "horizontal"The arrow-key axis and aria-orientation of the option list.
diruseLocale()
"ltr" | "rtl"Mirrors this Combobox’s navigation on its own, overriding the page’s direction. Unset, it follows the page.
size"md"
"sm" | "md" | "lg"The density scale — the control and the popup’s rows together.
chevronIconbuilt-in chevron
() => JSX.ElementThe trigger’s default chevron, as a factory. Overridable app-wide from a preset, and per instance with Combobox.Icon’s children.
checkIconbuilt-in check
() => JSX.ElementThe default selection glyph, as a factory. Overridable app-wide from a preset, and per instance with Combobox.ItemIndicator’s children.
clearIconbuilt-in cross
() => JSX.ElementThe clear button’s default glyph, as a factory. Overridable app-wide from a preset, and per instance with Combobox.Clear’s children.
slotClasses
SlotClasses<"combobox">Per-slot class overrides, keyed by control, input, clear, trigger, icon, positioner, content, list, empty, status, group, groupLabel, separator, item, itemText, itemIndicator.
children
JSX.ElementThe Combobox anatomy (Control + Portal > …).

Combobox.Input

Plus every native <input> attribute except valuearia-label or aria-labelledby among them, and one of the two is required. placeholder is the usual one to set. autocomplete, autocorrect, autocapitalize and spellcheck are switched off for you (the browser’s own suggestions would overlay the popup with a second, unrelated list) and defer to yours if you pass them.

PropDefaultType
render
(props) => JSX.ElementRender the input as another element or component. Receives its computed props, ref included — the ref is the element the text state writes into, so a target that drops it has no controlled value, no commit and no revert.
class
stringExtra classes merged onto the input slot (your utilities win conflicts).

Combobox.List

Plus every native <div> attribute except children.

PropDefaultType
children
(entry: G, index: Accessor<number>, items: Accessor<I[]>) => JSX.ElementA render callback invoked once per surviving entry: per item when flat, per group when groupToItems is set. index is what a Separator between groups keys off. The third argument is that group’s filtered items, and it is an accessor — read it inside the inner <For>, never in the callback body. G and I are inferred from the annotations you write on this callback.
render
(props) => JSX.ElementRender the list container as another element or component. Receives its computed props and the ref that registers it as the scroll container. Re-targets the container — not the same thing as the per-entry callback above.
class
stringExtra classes merged onto the list slot (your utilities win conflicts).

Combobox.Item

Plus every native <div> attribute — id is the exception: it is the aria-activedescendant target and is generated for you. Nothing else about the row is declared here; its label, disabled state and value all come from Combobox.Root’s accessors.

PropDefaultType
item
VRequired. The entry this option renders — one element of the filtered set Combobox.List handed you (or of a group’s own filtered items). The row resolves its own position from it, so it can sit at any depth. There is deliberately no index prop, and an item outside the current entries logs a development warning naming it — which is also the tripwire for rendering an unfiltered list.
render
(props) => JSX.ElementRender the option as another element or component. Receives its computed props and the ref that publishes the row — an aria-activedescendant target and what scroll-into-view moves.
class
stringExtra classes merged onto the item slot (your utilities win conflicts).

Combobox.Clear

Plus every native <button> attribute.

PropDefaultType
alwaysVisiblefalse
booleanWhether to render while the field is already empty. Off, the button appears only once there is text or a selection to clear — what every search field does.
nativeButtontrue
booleanSet false when a render target is not a real <button>: the disabled behavior switches to tabIndex / aria-disabled and keyboard activation is synthesized.
childrentheme clearIcon
JSX.ElementA custom glyph, replacing the theme’s default for this one button.
render
(props) => JSX.ElementRender the clear button as another element or component. Receives its computed props — the localized aria-label, the tabindex and the focus-preserving pointerdown ride on them.
class
stringExtra classes merged onto the clear slot (your utilities win conflicts).

Combobox.Trigger

Plus every native <button> attribute.

PropDefaultType
nativeButtontrue
booleanSet false when a render target is not a real <button>: the disabled behavior switches to tabIndex / aria-disabled and keyboard activation is synthesized.
children<Combobox.Icon />
JSX.ElementThe chevron. With none, renders Combobox.Icon.
render
(props) => JSX.ElementRender the chevron button as another element or component. Receives its computed props — the popup ARIA, the localized aria-label and the tabindex ride on them.
class
stringExtra classes merged onto the trigger slot (your utilities win conflicts).

Combobox.Status

Plus every native <div> attribute except children.

PropDefaultType
childrenlocalized count
JSX.Element | ((count: number) => JSX.Element)Replaces the default message. As a callback it receives the number of options the current filter left, so an app can reword or localize it.
render
(props) => JSX.ElementRender the status as another element or component. Receives its computed props — role="status" and the live-region attributes. Pick a target that can hold text and stays mounted.
class
stringExtra classes merged onto the status slot (your utilities win conflicts).

Combobox.Portal

PropDefaultType
mountdocument.body
ElementWhere to portal the Positioner.
children
JSX.ElementThe Positioner subtree.

Part props

Combobox.Control, Combobox.Icon, Combobox.Positioner, Combobox.Content, Combobox.Empty, Combobox.Group, Combobox.GroupLabel, Combobox.Separator, Combobox.ItemText, and Combobox.ItemIndicator each accept their native element attributes plus a render prop and a class. Combobox.Icon, Combobox.ItemIndicator and Combobox.Empty also take children — respectively a custom glyph replacing the theme’s default for that one instance, and the no-results message. aria-hidden is the one attribute the two glyph parts do not forward, since the input’s aria-expanded and the option’s aria-selected already convey what they show.

Accessibility

Combobox implements the WAI-ARIA combobox pattern (ARIA 1.2), with an editable role="combobox" and a role="listbox" popup.

  • Give the input an accessible name. There is no Label part, so pass aria-label — or aria-labelledby pointing at your own <label> — on Combobox.Input. A nameless role="combobox" is an axe aria-input-field-name violation, and the popup inherits its name from the input, so one missing attribute leaves both unnamed.
  • DOM focus never leaves the input. No option is ever focused; the highlight is aria-activedescendant on the input plus data-active on the row. Both gutter buttons sit outside the tab order and refuse focus on press, so the widget is a single tab stop and a click on the chevron never blurs the field.
  • The result count is announced. Combobox.Status is a role="status" live region reporting how many options the filter left — the one thing a combobox does that a screen reader cannot otherwise observe, since focus does not move and the list only gets shorter. The first count is announced through a body-level region, because a live region that mounts with its text is silent.
  • No dangling IDREFs. aria-controls and aria-activedescendant are present only while the popup is open, so a page of closed Comboboxes carries no attribute naming an element that isn’t in the DOM.
  • Roles and state are wired for you. The input is role="combobox" with aria-expanded and aria-autocomplete="list", the list role="listbox" (with aria-multiselectable in multiple mode), each option role="option" with aria-selected, groups role="group", and the separator role="presentation" — never role="separator", which is an invalid listbox child.
  • Both gutter buttons are named. The chevron and the clear button each carry a localized aria-label from @hope-ui/i18n, because a bare glyph is an axe button-name violation and unusable by voice control. Override either with your own aria-label.
  • One highlight, keyboard and pointer. The arrows and the cursor share a single active option, so the two can never paint two highlights; the selection check is separate from that transient highlight. An offscreen highlight is scrolled into view, and re-filtering re-anchors it to the first surviving row rather than leaving it pointing into the previous list.
  • Disabled options stay discoverable. A disabled row keeps aria-disabled and, by default, is skipped by navigation; it can never be selected.
  • Motion is reduced-motion aware. The card’s fade-and-scale drops to an instant open/close when the user has prefers-reduced-motion enabled.