Combobox
A multi-select, filterable text input that follows the WAI-ARIA APG combobox (list-autocomplete) pattern, with each pick rendered as a dismissible chip.
Import
import { Combobox } from "@hyzer-labs/ui"Demo
Select vs Combobox
Combobox when there are many options, where filtering or virtualization helps,
or when you need type-to-filter search. For a small, static option set, prefer the simpler
native Select, including its own native multiple.Type to filter, or open with the toggle/ArrowDown. Clicking an option (or
pressing Enter on it) toggles its membership. The popup stays open so you can
keep picking, and each pick becomes a dismissible chip. Backspace on an empty
query removes the last chip. A disabled option (Zone) stays visible but
cannot be toggled. Its chip is pre-selected here to show that an existing selection
stays removable even when the option itself is inert.
- Aviar
- Luna
- Judge
- Zone (out of stock)
- Punisher
const putters: FormOption[] = [
{ value: 'aviar', label: 'Aviar' },
{ value: 'luna', label: 'Luna' },
{ value: 'judge', label: 'Judge' },
{ value: 'zone', label: 'Zone (out of stock)', disabled: true },
{ value: 'punisher', label: 'Punisher' }
];
<Combobox name="putters" label="Putters" options={putters} bind:value={bag} />The default filter matches a substring anywhere in the label. It is
case-insensitive and not anchored to the start, so typing a fragment from the middle or
end of a label still matches it (see the Large list tab). Your own filter prop overrides that default wholesale. This demo deliberately swaps in a narrower one, start-of-label
only, to show what overriding looks like. It is not what Combobox does by default.
- Destroyer
- Wraith
- Buzzz
- Roc3
function startsWithFilter(query: string, option: FormOption): boolean {
return option.label.toLowerCase().startsWith(query.toLowerCase());
}
<Combobox name="discs" label="Discs" options={discs} filter={startsWithFilter} />58 real disc golf courses, filtered by the same substring-anywhere default
as every other tab. Try typing a fragment from the middle of a course or city
name, such as ridge or ville: it still matches, because the
match is not anchored to the start of the label.
Combobox does not window its listbox
<li>. A list this size is comfortable even on the unfiltered open,
but opening tens of thousands of options would mount that many nodes and visibly lag.
Past that scale, reach for the Virtualized combobox pattern, which
windows the listbox with Virtualizer.- Maple Hill · Leicester, MA
- DeLaveaga · Santa Cruz, CA
- Winthrop Gold · Rock Hill, SC
- Fox Run Golf Links · Lee's Summit, MO
- Northwoods · East Peoria, IL
- Lake Eureka · Eureka, IL
- Krape Park · Freeport, IL
- Cahokia Mounds · Collinsville, IL
- Idlewild · Burlington, KY
- Toboggan · Emporia, KS
- Peter Pan Park · Emporia, KS
- Emporia Country Club · Emporia, KS
- Shawnee Mission Park · Shawnee, KS
- The Preserve at Jones Park · Pierson, FL
- Flip City · Ormond Beach, FL
- Chain of Lakes Park · Delray Beach, FL
- International Disc Golf Center (Old North) · Appling, GA
- Hornets Nest Park · Charlotte, NC
- Bryan Park (Blue) · Greensboro, NC
- Harpeth Hills · Nashville, TN
- Warrior's Path State Park · Kingsport, TN
- Milo McIver State Park · Estacada, OR
- Blue Lake Park · Fairview, OR
- Pier Park · Portland, OR
- Alton Baker Park · Eugene, OR
- Blue Ribbon Pines · Montevideo, MN
- Elm Creek Park Reserve · Maple Grove, MN
- Bryant Lake Park · Eden Prairie, MN
- Hyland Hills · Bloomington, MN
- Highland Park · Saint Paul, MN
- Vista del Camino Park · Scottsdale, AZ
- Fountain Hills · Fountain Hills, AZ
- Steady Ed's Memorial · La Mirada, CA
- Golden Gate Park · San Francisco, CA
- Elysian Park · Los Angeles, CA
- Hudson Mills Metropark · Dexter, MI
- Iron Hill Park · Newark, DE
- Fort Steuben Park · Steubenville, OH
- Highbanks Metro Park · Lewis Center, OH
- Pyramid Hill Sculpture Park · Hamilton, OH
- Zilker Park · Austin, TX
- Pecan Grove · San Marcos, TX
- Camp Eder · Fairfield, PA
- Nockamixon State Park · Quakertown, PA
- Rocky Point Park · Warwick, RI
- Waveny Park · New Canaan, CT
- E.P. "Tom" Sawyer State Park · Louisville, KY
- Earlywine Park · Oklahoma City, OK
- Bell Slough Wildlife Management Area · Mayflower, AR
- Kessler Mountain Regional Park · Fayetteville, AR
- Fountainhead Regional Park · Fairfax Station, VA
- Marymoor Park · Redmond, WA
- Lake Sammamish State Park · Issaquah, WA
- Järva DiscGolfPark · Stockholm, Sweden
- Alytus Disc Golf Course · Alytus, Lithuania
- Nokia Disc Golf Park · Nokia, Finland
- Beringen DiscGolfPark · Beringen, Belgium
- Kastaniengarten · Bremervörde, Germany
import { COURSES, courseSlug } from './courses';
const manyCourses: FormOption[] = COURSES.map((course) => ({
value: courseSlug(course),
label: `${course.name} · ${course.location}`
}));
<Combobox name="courses" label="Course" options={manyCourses} />chipProps restyles every chip without importing Badge yourself. intent/variant/size/rounded/class pass straight through to the underlying Badge. The styling applies to every
chip; there is no per-option styling.
- MPO
- FPO
- MA1
- MA2
<Combobox
name="divisions"
label="Divisions"
options={divisions}
chipProps={{ intent: 'primary', variant: 'solid', rounded: 'md' }}
/>description and error are announced with the field: both chain
into aria-describedby, even while the popup visually covers that region.
Error and disabled are also field states, and the wrapper's data-state reflects them, with error winning. Even then, a disabled field still puts native disabled on the input and toggle, and its chips lose their dismiss buttons.
- Short tees
- Long tees
Pick every tee your card is playing today.
- MPO
- FPO
- MA1
- MA2
Pick at least one division.
- MPO
- FPO
- MA1
- MA2
- Short tees
- Long tees
<Combobox
name="tees"
label="Tee position"
options={tees}
description="Pick every tee your card is playing today."
/>
<Combobox name="division-error" label="Divisions" options={divisions} error="Pick at least one division." />
<Combobox name="division-required" label="Divisions" options={divisions} required />
<Combobox name="tees-disabled" label="Tee position" options={tees} disabled />Props
| Name | Type | Default | Note |
|---|---|---|---|
name | string | — | Required. Repeated on every hidden input. |
label | string | — | Required. Always in the DOM; sr-only with hideLabel. |
options | FormOption[] | — | Required. Flat, multi-select — see FormOption below. |
value | string[] | [] | Bindable. Selected option values, in selection order. |
placeholder | string | 'Search...' | Shown only while nothing is selected — once chips exist, they say it better. |
filter | (query: string, option: FormOption) => boolean | — | Overrides the default case-insensitive substring match. |
emptyText | string | 'No results' | |
toggleLabel | string | 'Show options' | |
chipProps | ComboboxChipProps | {} | Styling applied to every chip — see ComboboxChipProps below. Resolves to size: 'sm'. |
onchange | (value: string[]) => void | — | Fires on every selection change: toggle, chip dismiss, or Backspace removal. |
description | string | — | Help text below the label. |
error | string | — | Inline error message; sets the error state. |
required | boolean | false | |
disabled | boolean | false | |
hideLabel | boolean | false | |
class | string | — | Merged after the hz-field hz-combobox classes. |
FormOption
| Name | Type | Default | Note |
|---|---|---|---|
value | string | — | Required. |
label | string | — | Required. Shown on the chip and in the option row. |
disabled | boolean | — | Inert — visible but never toggleable; an existing selection stays removable. |
ComboboxChipProps
| Name | Type | Default | Note |
|---|---|---|---|
intent | Intent | 'neutral' | See Foundation → Colors & Intent. |
variant | 'soft' | 'solid' | 'outline' | 'soft' | |
size | 'sm' | 'md' | 'sm' | Combobox's own default overrides Badge's md default. |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'full' | 'full' | |
class | string | — |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-field hz-combobox
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-state | 'default' | 'error' | 'disabled' | The universal field state hook, on the root. Precedence is fixed: error beats disabled. |
data-open | present while the popup is open | On the root. Rotates the toggle chevron and gates the popup. |
data-active | present on the active option | Virtual focus — the highlighted option. Real focus stays in the input. |
data-selected | present on selected options | Selected options; the theme adds a checkmark. |
data-disabled | present on disabled options | Per-option. Disabled options stay focusable (aria-disabled, not the native attribute). |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-field-label | child element | The label. |
.hz-field-required | child element | The required marker. |
.hz-field-description | child element | The hint text. |
.hz-field-error | child element | The error message. |
.hz-combobox-control | child element | The bordered row holding the input, chips, and toggle. |
.hz-combobox-input | child element | The text input. |
.hz-combobox-toggle | child element | The chevron button. |
.hz-combobox-popup | child element | The floating panel. |
.hz-combobox-listbox | child element | The option list. |
.hz-combobox-option | child element | One option. |
.hz-combobox-empty | child element | The no-results message. |
Accessibility
The visible input carries role=combobox with aria-autocomplete=list, aria-expanded, and aria-controls pointing at the role=listbox popup, which is aria-multiselectable. DOM focus never moves into the list: the highlighted option is tracked as virtual focus with aria-activedescendant on the input, so screen readers announce the active option while the field stays editable.
description/error chain into aria-describedby on the input (description first), still computed even while the popup visually overlays that region. required sets aria-required, and an error sets aria-invalid.
Each selected value renders as a Badge chip inside the control. Every chip has a labeled dismiss button, announced as Remove followed by the option's label. Each of those buttons is a real tab stop of its own, so tabbing into the field reaches the chips before the input.
Keyboard: ArrowDown/ArrowUp open the popup and move the active option, wrapping and skipping disabled entries. Alt+ArrowDown opens without moving, and Alt+ArrowUp closes without changing anything. Home/End jump to the first and last enabled option while open (they're native text-cursor moves while closed). Enter toggles the active option's membership and keeps the popup open so you can keep picking. Backspace on an empty query removes the last chip. Escape closes the popup and clears the filter query. It never clears a selection, since every chip has its own dismiss button. Tab closes the popup and lets focus move on.
The input is the only tab stop that opens the popup. The trailing toggle button is tabindex=-1, reachable by pointer only.
References: APG Combobox pattern