Headless UI Combobox for Filters
Permalink to "Headless UI Combobox for Filters"Headless UI, from the Tailwind team, provides unstyled accessible components for React and Vue. Its Combobox implements the ARIA combobox pattern with a listbox popup: role="combobox" on the input, aria-expanded, aria-controls, aria-activedescendant for the active option, keyboard navigation and selection. In data UIs it is a good base for searchable filters — “Customer”, “Status”, “Tags” — where users type to narrow a list of values. Headless UI handles the combobox; the filter-specific work is labelling, option text, multi-select display and announcing what the filter did to the data.
This guide belongs to headless UI primitives for data.
Spec reference
Permalink to "Spec reference"- ARIA combobox pattern (1.2) — input with
role="combobox",aria-expanded,aria-controlsto alistbox,aria-activedescendantfor the active option. - Headless UI Combobox —
Combobox,ComboboxInput,ComboboxButton,ComboboxOptions,ComboboxOption,Label(viaField);multiplefor multi-select;byfor object comparison. - SC 1.3.1, 4.1.2 (A) — labelled input, named options, selection state.
- SC 4.1.3 (AA) — result counts after filtering.
When to use — and when not to
Permalink to "When to use — and when not to"Use a combobox filter when the list of possible values is long (customers, tags, accounts) and typing to narrow helps. For short lists (five statuses), checkboxes or a select are simpler.
Do not hide the label in a placeholder. Do not re-render the grid on every keystroke in the combobox input — filter the options as users type, and apply the filter to the grid on selection.
The misapplication: a combobox whose options are rendered in a portal far from the input with a separate component for each option list, breaking aria-activedescendant references when components use separate shadow roots or IDs are not stable.
Annotated code example
Permalink to "Annotated code example"import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption, Field, Label } from '@headlessui/react';
function CustomerFilter({ customers, selected, onChange }) {
const [query, setQuery] = useState('');
const options = query ? customers.filter(c => c.name.toLowerCase().includes(query.toLowerCase())) : customers;
return (
<Field>
<Label>Customer</Label> {/* visible label (SC 1.3.1) */}
<Combobox multiple value={selected} onChange={onChange} by="id">
<ComboboxInput onChange={(e) => setQuery(e.target.value)} displayValue={() => query} />
<ComboboxOptions>
{options.length === 0 && <div className="empty">No customers match “{query}”</div>} {/* visible empty state */}
{options.map((c) => (
<ComboboxOption key={c.id} value={c}>
{c.name} <span className="count">({c.invoiceCount} invoices)</span> {/* count in the name */}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
<ul className="chips" aria-label="Selected customers">
{selected.map((c) => (
<li key={c.id}>{c.name}
<button type="button" aria-label={`Remove ${c.name}`} onClick={() => onChange(selected.filter(s => s.id !== c.id))}>×</button>
</li>
))}
</ul>
</Field>
);
}
// Elsewhere: when selected changes, filter the grid and announce `${rows} invoices shown` (SC 4.1.3)
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Behaviour | Expected announcement |
|---|---|---|
| Tab to input | Focus | “Customer, combo box, collapsed” |
| Typing | Options narrow; list opens | Option count may be announced by some readers |
| Down / Up | Move active option | “Lund & Co (42 invoices), 1 of 3” |
| Enter | Select (toggle in multiple) | Selected state; chip added |
| Escape | Close list | “collapsed” |
| Tab to chip remove | Focus | “Remove Lund & Co, button” |
Integration context
Permalink to "Integration context"Multi-select combobox filters are covered in depth in accessible multi-select combobox for column filters. Applied filter chips follow applied filter chips and removal, and result counts announcing filter result counts with aria-live. An alternative primitive is Downshift for autocomplete filters.
Gotchas
Permalink to "Gotchas"displayValue with multiple selection. Showing selected names inside the input confuses users; keep the input for the query and show selections as chips.
Async options. When options load from a server, show a “Searching…” state inside the options list and avoid announcing every intermediate result.
Removing chips. After removing a chip, move focus to the next chip or back to the input, never to the body.
Worked example
Permalink to "Worked example"A CRM’s account filter used a custom dropdown with a search box, unlabelled except for a placeholder, applying the filter to the grid on every keystroke. Screen reader users could not tell what the field was once they started typing, and the grid’s constant re-rendering caused a flood of announcements. Rebuilding on Headless UI’s Combobox with a visible label, option names with counts, selection on Enter, chips with remove buttons and a single result-count announcement after each change made the filter quiet and clear.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Tab to the filter; confirm the label and “combo box”.
- Type a few letters; confirm options narrow and the active option is announced as you arrow.
- Select two values; confirm chips appear with named remove buttons.
- Confirm the grid result count is announced once after each change.
- Remove a chip; confirm focus moves to a sensible place and the new count is announced.
Common mistakes
Permalink to "Common mistakes"- Placeholder as the only label.
- Filtering the grid on every keystroke.
- Selected values crammed into the input.
- No empty-options message.
Adoption checklist
Permalink to "Adoption checklist"- Wrap Combobox in a
FacetFiltercomponent with required label and chip list. - Include counts in option text where available.
- Apply filters on selection, not on query typing.
- Connect to the shared announcer for result counts.
- Test with NVDA, JAWS and VoiceOver, which differ in combobox announcements.
FAQ
Permalink to "FAQ"Does Headless UI's Combobox follow the ARIA pattern?
Yes; it implements the combobox with a listbox popup, including active descendant management.
Should options include counts?
When counts are cheap and accurate, they help users choose; include them in the option text.
How do I support "select all"?
Add an explicit “Select all matching” option or button, and announce how many were added.
What about Vue?
Headless UI provides Vue components with the same patterns.
How should the number of options be announced?
Some screen readers announce list size; you can add a visually hidden status like “3 customers found” after typing pauses.
Can I use it for single-select filters?
Yes; without multiple, the selected option can be displayed in the input.
How do I clear all selections?
Provide a “Clear Customer filter” button next to the chips and announce the result.
Can the combobox open on focus?
Avoid it; opening on focus surprises keyboard users and screen reader users moving through the form. Open on typing or the Down key.
How do I handle a large number of options?
Filter server-side and show the top matches with a hint to keep typing; avoid rendering thousands of options.
Related
Permalink to "Related"- Downshift for autocomplete filters — a hooks-based alternative
- Multi-select combobox for column filters — the pattern in depth
- Applied filter chips — showing selections