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-controls to a listbox, aria-activedescendant for the active option.
  • Headless UI Combobox — Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption, Label (via Field); multiple for multi-select; by for 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.
From typing to filtered grid Chain: the user types in the Customer filter; options narrow; arrow keys move the active option; Enter selects; the chip appears; the grid filters and the status announces the count. From typing to filtered gridType "lu"options narrow to 3Active optionaria-activedescendantSelectedchip "Lund & Co"addedFilter appliedrows recomputedarrowsEntergridStatus"42 invoices shown"
Headless UI handles the combobox; the grid message is yours.

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.

Placeholder-only filter versus labelled combobox Comparison: a placeholder-only filter loses its name when typing starts and applies on every keystroke; a labelled combobox keeps its name, filters options as users type and applies on selection with a count announcement. Placeholder-only filter versus labelled combobox✗ Placeholder "Customer…" onlyName disappears once typing startsGrid refilters on every keystrokeSelections hidden in the inputNo result count✓ Visible label "Customer"Name persistsGrid filters on selectionRemovable chips with names"42 invoices shown"

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”
Filter control choices by list size Matrix of list sizes — up to 7 values, 8 to 50, more than 50, free text — against checkboxes, select, combobox and search input. Filter control choices by list sizeCheckboxesSelectComboboxSearch inputUp to 7 valuesBestGoodOverkillNo8 to 50 valuesLongGoodGoodNoMore than 50 valuesToo longHard to scanBestNoFree textNoNoIf suggestionsBest
Comboboxes earn their complexity for long lists.

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"
  1. Tab to the filter; confirm the label and “combo box”.
  2. Type a few letters; confirm options narrow and the active option is announced as you arrow.
  3. Select two values; confirm chips appear with named remove buttons.
  4. Confirm the grid result count is announced once after each change.
  5. 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"
  1. Wrap Combobox in a FacetFilter component with required label and chip list.
  2. Include counts in option text where available.
  3. Apply filters on selection, not on query typing.
  4. Connect to the shared announcer for result counts.
  5. 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.

Permalink to "Related"

← Back to Headless UI Primitives for Data