Combobox#

An editable text input over a filtered listbox: the WAI-ARIA editable combobox pattern. Focus stays in a real <input role="combobox">, the highlighted option is conveyed through aria-activedescendant, and the items are JSX children you filter yourself — zero registers, highlights and selects them, and prunes a highlight whose item unmounts mid-typing.

Import#

TSX
import { Combobox } from '@sigx/zero/combobox';

Combobox is a compound: Combobox.Root, Combobox.Control, Combobox.Input, Combobox.Trigger, Combobox.Popup, Combobox.Group, Combobox.GroupLabel, Combobox.Item, Combobox.Empty. It is also re-exported from the @sigx/zero root, together with comboboxAnatomy, useComboboxContext and useComboboxGroupContext.

Usage#

TSX
import { component } from 'sigx';
import { Combobox } from '@sigx/zero/combobox';

const fruits = ['Apple', 'Banana', 'Cherry', 'Lime'];

const FruitSearch = component(({ signal }) => {
    const state = signal({ fruit: '', query: '' });
    const matches = () => fruits.filter((f) => f.toLowerCase().includes(state.query.toLowerCase()));

    return () => (
        <Combobox.Root model={() => state.fruit} model:inputValue={() => state.query} name="fruit">
            <Combobox.Control>
                <Combobox.Input placeholder="Search fruit…" />
                <Combobox.Trigger />
            </Combobox.Control>
            <Combobox.Popup>
                {matches().map((f) => (
                    <Combobox.Item value={f} key={f}>{f}</Combobox.Item>
                ))}
                {matches().length === 0 ? <Combobox.Empty>No fruit found</Combobox.Empty> : null}
            </Combobox.Popup>
        </Combobox.Root>
    );
});

Filtering is yours: bind model:inputValue (or listen to inputValueChange), render only the items that match, and render Combobox.Empty yourself when nothing does. Zero owns no emptiness logic. Selecting an item writes its value to the model and fills the input with the item's text; an external value write (a form reset, server data) reflects into the input once the matching item is known.

Named models#

Combobox has three pieces of controllable state, each a model:

ModelTypeUncontrolled defaultChange event
modelstringdefaultValuevalueChange
model:inputValuestringdefaultInputValueinputValueChange
model:openbooleandefaultOpenopenChange

The unnamed model is the essential value — what the hidden input posts. Every additional piece of state is a named model, bound as model:<name> in JSX, with the same default<Name> + <name>Change companions. Any of the three may be left uncontrolled. See Models.

Option groups#

TSX
<Combobox.Popup>
    <Combobox.Group>
        <Combobox.GroupLabel>Citrus</Combobox.GroupLabel>
        <Combobox.Item value="lime">Lime</Combobox.Item>
    </Combobox.Group>
</Combobox.Popup>

Combobox.Group renders role="group" inside the listbox, named by its GroupLabel through aria-labelledby while one is rendered — exactly as in Select. Your filter renders or omits whole groups.

The options sugar#

TSX
<Field.Root>
    <Field.Label>Fruit</Field.Label>
    <Combobox.Root
        model={() => state.fruit}
        model:inputValue={() => state.query}
        options={matches().map((f) => ({ value: f }))}
    />
</Field.Root>

With no slot children, options renders the default composition — Control(Input, Trigger) and a Popup with an Item per entry and a Group + GroupLabel per distinct group in first-appearance order, label defaulting to value — through the same anatomy. It is rendering sugar only: filtering stays yours, so bind model:inputValue and pass an already-narrowed array. Slot children win entirely when both are given. Name an options-driven Combobox through a Field — its label lands on the generated input via the control id.

Anatomy#

PartElementStatesFlagsNotes
rootdivdisabled, invalid, requiredCarries the variant axes.
controldivopen | closeddisabled, invalid, focus-visibleThe field chrome wrapping input + trigger; mirrors the input's focus so the ring draws on the box. Inside root.
inputinputopen | closeddisabled, invalid, required, readonly, focus-visiblerole="combobox", aria-autocomplete="list", aria-expanded, aria-controls, aria-activedescendant. Inside control.
triggerbuttonopen | closeddisabled, pressed, press-animating, focus-visibletabIndex=-1, aria-label, aria-expanded, aria-controls. A pointer affordance. Publishes press feedback. asChild.
popupdivopen | closedrole="listbox", popover="manual", aria-labelledby the input. data-placement on every placement.
groupdivrole="group", aria-labelledby its label while one is rendered. Inside popup.
group-labeldivNo role — it stays in the accessibility tree to name the group. Inside group.
itemdivselected, highlighted, disabled, pressed, press-animatingrole="option", aria-selected, aria-disabled. Pointer-only press feedback. asChild.
item-indicatorspanselectedaria-hidden; rendered inside the selected item only.
emptydivrole="presentation"; rendered by the consumer when the filtered list is empty. Inside popup.
hidden-inputinputtype="hidden", carries name and the selected value for form posts.

Every part carries data-scope="combobox" and data-part="<part>". The root is the carrier part for the variant axes. The input carries no data-placeholder flag — it is a real text input, so :placeholder-shown is the selector. The popup is popover="manual" plus zero's dismiss layer: native auto light dismiss would close the list on a caret click in the input, so dismissal is outside-press only, with Escape owned by the input's key handler. It is positioned against the control through the positioning behavior. See The anatomy contract.

Props#

Combobox.Root#

PropTypeDefaultDescription
modelstringTwo-way binding of the selected item's value.
defaultValuestring''Initial value when uncontrolled.
valueChangeevent (value: string)Fires whenever the selection changes.
model:inputValuestringTwo-way binding of the input's text.
defaultInputValuestring''Initial text when uncontrolled.
inputValueChangeevent (value: string)Fires on every keystroke and on selection.
model:openbooleanTwo-way binding of the popup's open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
openChangeevent (open: boolean)Fires whenever the popup opens or closes.
placeholderstringThe input's placeholder; Combobox.Input's own prop wins.
namestringForm field name, rendered on the hidden input.
requiredbooleanfalseRenders required and data-required on the input.
invalidbooleanfalseRenders aria-invalid and data-invalid.
readonlybooleanfalseThe input is readOnly; keys and the trigger do nothing.
disabledbooleanfalseInert; renders data-disabled.
placementPlacement'bottom-start'Preferred popup side and alignment.
positionStrategyPositionStrategyfixed positioningHow the popup is placed against the control.
optionsReadonlyArray<OptionInput>{ value, label?, disabled?, group? }[]; renders the default composition when there are no slot children.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes, rendered as data-* on root.
classstringExtra classes on the root element.

Field context (disabled, invalid, required, aria-describedby, the control id on the input) is merged with the props.

Combobox.Input#

PropTypeDefaultDescription
placeholderstringthe root's placeholderNative placeholder text.
classstringExtra classes.

Combobox.Trigger#

PropTypeDefaultDescription
labelstring'Show options'aria-label for the disclosure button.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

The default content is a chevron glyph. A click toggles the popup and returns focus to the input.

Combobox.Control, Combobox.Popup, Combobox.Group, Combobox.GroupLabel, Combobox.Empty#

Only class.

Combobox.Item#

PropTypeDefaultDescription
valuestringrequiredThe value this item selects.
textValuestringthe item's text contentWhat fills the input on selection; set it when the content is not plain text.
disabledbooleanfalseSkipped by the highlight; renders data-disabled and aria-disabled.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstringExtra classes.

Keyboard#

Focus stays in the input throughout; options never receive focus, and there is no typeahead — typing is the filter.

KeyAction
TypingWrites model:inputValue and opens the popup.
ArrowDown / ArrowUp (closed)Open the popup with the highlight on the first / last enabled item.
ArrowDown / ArrowUp (open)Move the highlight to the next / previous enabled item.
EnterSelect the highlighted item and close; otherwise not swallowed, so a form submits.
EscapeClose the popup (only swallowed while it is open).
TabClose and leave; not swallowed.
Home / EndStay with the text caret.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (all eight recommended roles) and size (xsxl) on combobox, so <Combobox.Root color="primary" size="sm"> is styled in both. Neither wires a variant on the scope; under a design system's /register import the prop is therefore absent. Both draw the well and the focus ring on control, style the popup's open / closed transition and the highlighted / selected item flags, and give empty its muted text. See Typed vocabulary.

Select for a select-only listbox, Field for labelling.