Combobox
StableA text field that suggests options as you type, for long lists like time zones or skills (APG list autocomplete).
Typing filters the list and highlights the first match; ↑ ↓ move and Enter chooses, while focus stays in the field. Leaving the field keeps an exact match, clears an empty field and otherwise puts back the last choice, so the value is always an option or null. The list opens in the top layer, so cards and app windows never clip it.
Time zone
import { useState } from 'react'import { Combobox, type ComboboxOption } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** Cities by their IANA time zone. Keywords let “Seattle” or “Germany” find the right one. */const zones: ComboboxOption[] = [ { value: 'Pacific/Honolulu', label: 'Honolulu', hint: 'Hawaii' }, { value: 'America/Anchorage', label: 'Anchorage', hint: 'Alaska' }, { value: 'America/Los_Angeles', label: 'Los Angeles', hint: 'Pacific', keywords: ['San Francisco', 'Seattle', 'Vancouver'] }, { value: 'America/Denver', label: 'Denver', hint: 'Mountain', keywords: ['Salt Lake City'] }, { value: 'America/Phoenix', label: 'Phoenix', hint: 'Arizona' }, { value: 'America/Chicago', label: 'Chicago', hint: 'Central', keywords: ['Dallas', 'Austin', 'Mexico City'] }, { value: 'America/New_York', label: 'New York', hint: 'Eastern', keywords: ['Boston', 'Toronto', 'Miami'] }, { value: 'America/Sao_Paulo', label: 'São Paulo', hint: 'Brazil', keywords: ['Sao Paulo', 'Rio de Janeiro'] }, { value: 'Europe/London', label: 'London', hint: 'UK', keywords: ['Dublin', 'Lisbon'] }, { value: 'Europe/Berlin', label: 'Berlin', hint: 'Central Europe', keywords: ['Germany', 'Paris', 'Amsterdam', 'Madrid'] }, { value: 'Europe/Kyiv', label: 'Kyiv', hint: 'Eastern Europe', keywords: ['Ukraine', 'Athens', 'Helsinki'] }, { value: 'Asia/Dubai', label: 'Dubai', hint: 'Gulf' }, { value: 'Asia/Kolkata', label: 'Kolkata', hint: 'India', keywords: ['Bengaluru', 'Mumbai', 'Delhi'] }, { value: 'Asia/Singapore', label: 'Singapore', hint: 'Singapore' }, { value: 'Asia/Tokyo', label: 'Tokyo', hint: 'Japan', keywords: ['Osaka'] }, { value: 'Australia/Sydney', label: 'Sydney', hint: 'Australia East', keywords: ['Melbourne'] }, { value: 'Pacific/Auckland', label: 'Auckland', hint: 'New Zealand' },]
export default function ComboboxTimeZone() { const [zone, setZone] = useState<string | null>('America/Los_Angeles')
return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Field label="Time zone" hint="Type a city."> {(p) => <Combobox {...p} name="time-zone" listLabel="Time zones" options={zones} value={zone} onChange={setZone} placeholder="Type a city" />} </Field> <p className="font-mono text-[11px] text-fg-3">value: {zone ?? 'null'}</p> </div> )}Usage
import { Combobox, type ComboboxOption, type ComboboxProps } from '@rchursin/product/forms'<Field label="Time zone"> {(p) => <Combobox {...p} listLabel="Time zones" options={zones} value={zone} onChange={setZone} />}</Field>Examples
Hints and no matches
A quiet hint on the right of each option. Words inside a name match at their start, and “zzz” shows the empty text.
import { useState } from 'react'import { Combobox, type ComboboxOption } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** Sample skills with their runs in the last 30 days as a quiet hint on the right. */const skills: ComboboxOption[] = [ { value: 'pr-review', label: 'pr-review', hint: '212 runs' }, { value: 'test-fixer', label: 'test-fixer', hint: '34 runs' }, { value: 'release-notes', label: 'release-notes', hint: '28 runs' }, { value: 'changelog-writer', label: 'changelog-writer', hint: '19 runs' }, { value: 'dep-updater', label: 'dep-updater', hint: '17 runs' }, { value: 'sql-tuner', label: 'sql-tuner', hint: '9 runs' }, { value: 'flaky-test-hunter', label: 'flaky-test-hunter', hint: '6 runs' }, { value: 'openapi-writer', label: 'openapi-writer', hint: '4 runs' }, { value: 'docs-linter', label: 'docs-linter', hint: '3 runs' }, { value: 'api-docs', label: 'api-docs', hint: '0 runs' },]
/** Words in a name match at their start: “test” finds test-fixer and flaky-test-hunter. Try “zzz” for the empty state. */export default function ComboboxSkills() { const [skill, setSkill] = useState<string | null>(null)
return ( <div className="w-full max-w-xs"> <Field label="Skill" hint="Sample skills. Runs in the last 30 days on the right."> {(p) => <Combobox {...p} listLabel="Skills" options={skills} value={skill} onChange={setSkill} placeholder="Type a skill name" emptyText="No skill with that name" />} </Field> </div> )}API
Combobox props
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | ComboboxOption[] | – | Everything that can be chosen. |
valuerequired | string | null | – | The chosen option’s value, or null. |
onChangerequired | (value: string | null) => void | – | Called with the chosen value, or null when the field is cleared. |
listLabelrequired | string | – | Accessible name of the list, e.g. "Time zones". Also names the toggle: “Show time zones”. |
placeholder | string | – | A short example, e.g. "Type a city". |
size | 'md' | 'lg' | 'md' | Height 32 or 40 pixels. |
name | string | – | Adds a hidden input with the value, so a form submits the value rather than the label. |
emptyText | string | 'No matches' | Shown in place of the list when nothing matches. |
limit | number | 50 | Most suggestions shown at once; typing narrows the rest. |
disabled | boolean | – | Dims the field and the toggle. |
id | string | – | id of the input. Set by Field’s render prop ({...p}), along with aria-describedby and aria-invalid. |
className | string | – | Classes for the wrapper. |
ComboboxOption
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | – | What onChange receives. |
labelrequired | string | – | What people read and type. The typed part is drawn bold. |
hint | string | – | Quiet text on the right, e.g. "Pacific" or "34 runs". |
keywords | string[] | – | Extra words that match, e.g. other cities in the same zone. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Down arrow | Opens the list on the current choice (or the first option); then moves down, wrapping at the end. |
| Up arrow | Opens the list on the last option; then moves up, wrapping at the top. |
| AltDown arrow | Opens the list without highlighting anything. |
| Enter | Chooses the highlighted option and closes the list. |
| Esc | Closes the list. Pressed again with the list closed, clears the field. |
| Tab | Closes the list and moves on. An exact match is kept, an empty field clears the value, and anything else puts back the last choice. |
- The input has role="combobox" with aria-autocomplete="list", aria-expanded and aria-controls. The list is a listbox named by listLabel, and each option carries aria-selected.
- Focus never leaves the input: the highlighted option is announced through aria-activedescendant and scrolled into view.
- The chevron button opens and closes the list for pointer and touch; it is out of the tab order because the arrow keys do the same.
- Options whose label starts with the typed text (or has a word that does) come first, then any label or keyword that contains it.
- The chosen option has a check mark and the typed part of each label is bold, so neither depends on colour.
Guidelines
Do
- Use it when the list is too long to scan: dozens of options or more.
- Add keywords for the other words people will type.
- Pass name inside a form, so the value is submitted, not the label.
Don’t
- Don’t use it for fewer than about ten options; a Select or radio group is faster.
- Don’t accept free text with it; every value must be one of the options.
In use
Related
- SelectThe browser’s own select in the field finish, with an up-down chevron, so phones show their own picker and the keys work for free.
- PickerA filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
- FieldLabel above, hint under it, error in words between the hint and the control, with the ids and aria wiring done for you.
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
Source
The component’s own files, exactly as they run on this site.
/** * Combobox: a text field that suggests options as you type (WAI-ARIA APG, "list autocomplete * with automatic selection"). For long lists where typing is faster than scrolling: time zones, * skills, countries. * * <Field label="Time zone"> * {(p) => <Combobox {...p} listLabel="Time zones" options={zones} value={tz} onChange={setTz} />} * </Field> * * Keys: type to filter (the first match is highlighted), ↓ ↑ move, Enter chooses, Esc closes * the list (or clears the field when it is already closed), Alt+↓ opens without moving. * Focus never leaves the input; the highlight is aria-activedescendant. Leaving the field keeps * an exact match, clears an empty field, and otherwise restores the last choice. * * The list sits in the top layer (popover="manual"), so app windows and cards never clip it. */import { useEffect, useId, useLayoutEffect,