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.

Tags, and values the list does not hold#

TSX
<Field.Root>
    <Field.Label>Tools</Field.Label>
    <Combobox.Root
        items={tools}
        multiple
        allowCustom
        defaultValue={['search', 'shell']}
        name="tools"
        placeholder="Add a tool…"
        emptyText="Press Enter to add it"
    />
</Field.Root>

Under multiple, the data expansion (a root with items and no children) renders one Combobox.Tag per chosen value, in selection order, in the control before the input. A tag's default content is a Combobox.TagLabel and a Combobox.TagRemove — a real <button> in the tab order, named Remove <label>, which hands focus back to the input. Backspace on an empty input removes the last value, so the remove buttons are how a keyboard user reaches any other tag. A tag keeps its label after a consumer-filtered item unmounts.

The root's tag slot replaces a tag's content — for a per-tag control such as a mode select. It receives { value, label, item }, where item is the data item (undefined for a custom value):

TSX
<Combobox.Root items={tools} multiple slots={{
    tag: ({ value, label }) => (
        <>
            <Combobox.TagLabel />
            <ModePicker tool={value} />
            <Combobox.TagRemove label={`Remove ${label}`} />
        </>
    ),
}} />

A hand-written root places the tags itself: Combobox.Tags inside Combobox.Control renders one Combobox.Tag value={key} per chosen value (its default slot takes the same { value, label, item }), or write individual Combobox.Tags.

allowCustom commits the typed text on Enter while no option is highlighted. A chosen value, or an option whose label the text names (case-insensitively), wins; otherwise the trimmed text itself becomes the value. Under multiple it adds a tag and clears the input; in single mode it sets the value and closes. A custom value posts through the hidden <select> like any other — data mode posts chosen values the items do not hold. The overloads type allowCustom only for a string model: hand-written items, string items, or a string itemValue.

Anatomy#

PartElementStatesFlagsNotes
rootdiv—disabled, 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.
tagspan—disabledOne chosen value under multiple. Inside control, before the input.
tag-labelspan——The tag's text. Inside tag.
tag-removebutton—disabled, focus-visible, pressed, press-animatingaria-label Remove <label>; removes the value and returns focus to the input. Inside tag.
triggerbuttonopen | closeddisabled, pressed, press-animating, focus-visibletabIndex=-1, aria-label, aria-expanded, aria-controls. A pointer affordance. Publishes press feedback. asChild.
popupdivopen | closed—role="listbox", popover="manual", aria-labelledby the input. data-placement on every placement.
groupdiv——role="group", aria-labelledby its label while one is rendered. Inside popup.
group-labeldiv——No role — it stays in the accessibility tree to name the group. Inside group.
itemdiv—selected, highlighted, disabled, pressed, press-animatingrole="option", aria-selected, aria-disabled. Pointer-only press feedback. asChild.
item-indicatorspan—selectedaria-hidden; rendered inside the selected item only.
emptydiv——role="presentation"; rendered by the consumer when the filtered list is empty. Inside popup.
hidden-inputinput——type="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
modelstring—Two-way binding of the selected item's value.
defaultValuestring''Initial value when uncontrolled.
valueChangeevent (value: string)—Fires whenever the selection changes.
model:inputValuestring—Two-way binding of the input's text.
defaultInputValuestring''Initial text when uncontrolled.
inputValueChangeevent (value: string)—Fires on every keystroke and on selection.
model:openboolean—Two-way binding of the popup's open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
openChangeevent (open: boolean)—Fires whenever the popup opens or closes.
placeholderstring—The input's placeholder; Combobox.Input's own prop wins.
namestring—Form 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 vocabulary—The variant axes, rendered as data-* on root.
classstring—Extra 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.
classstring—Extra classes.

Combobox.Trigger#

PropTypeDefaultDescription
labelstring'Show options'aria-label for the disclosure button.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstring—Extra 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.Tags, Combobox.Tag, Combobox.TagLabel, Combobox.TagRemove#

MemberProps
Combobox.TagsA default slot receiving { value, label, item }, rendered per chosen value. Renders no element of its own.
Combobox.Tagvalue (string, required — the chosen value's key), class, default slot (replaces label + remove).
Combobox.TagLabelclass, default slot (replaces the label text).
Combobox.TagRemovelabel (string, default Remove <label>), class, default slot (replaces the × glyph). An app aria-label also replaces the default name.

On Combobox.Root, multiple (boolean) holds an array of values, and allowCustom (boolean) commits free text on Enter as described above.

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.
classstring—Extra 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. With allowCustom and no highlight, commit the typed text.
Backspace (empty input)Under multiple, remove the last chosen value.
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 (xs–xl) 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.