Downshift for Autocomplete Filters
Permalink to "Downshift for Autocomplete Filters"Downshift, maintained by the Testing Library community, is a set of React hooks that implement the ARIA combobox and select patterns while leaving every element’s markup and styling to you. You spread prop getters onto your own elements, and Downshift manages state, ARIA attributes, keyboard handling and a status message of its own (it announces the number of results). For data grid filters — searchable customers, accounts, tags — it is a flexible, well-tested base.
This guide belongs to headless UI primitives for data.
Spec reference
Permalink to "Spec reference"useCombobox— returns prop getters:getLabelProps,getInputProps(role combobox, aria-expanded, aria-controls, aria-activedescendant),getMenuProps(listbox),getItemProps(option, aria-selected).useMultipleSelection— manages selected items and keyboard interaction with them (Backspace, arrow keys between selected items).- Downshift status — Downshift maintains an
aria-livestatus element and announces result counts; configure the message withgetA11yStatusMessage(or disable it if you announce elsewhere). - Relevant criteria: SC 1.3.1, 4.1.2 (A), SC 4.1.3 (AA).
When to use — and when not to
Permalink to "When to use — and when not to"Use Downshift when you need full control of markup and styling — design systems often build their combobox on it. Use a component library’s combobox when its look and API fit; Downshift is for custom builds.
Do not combine Downshift’s status messages with your own announcements of the same information; choose one source for “N results”. Do not forget to spread prop getters on every element — missing one breaks the ARIA relationships.
The misapplication: spreading getInputProps but rendering the menu without getMenuProps, so aria-controls and aria-activedescendant point at nothing.
Annotated code example
Permalink to "Annotated code example"import { useCombobox, useMultipleSelection } from 'downshift';
function TagFilter({ allTags, onChange }) {
const [input, setInput] = useState('');
const ms = useMultipleSelection({ onSelectedItemsChange: ({ selectedItems }) => onChange(selectedItems) });
const items = allTags.filter(t => !ms.selectedItems.includes(t) && t.toLowerCase().includes(input.toLowerCase()));
const cb = useCombobox({
items, inputValue: input, selectedItem: null,
onInputValueChange: ({ inputValue }) => setInput(inputValue ?? ''),
onSelectedItemChange: ({ selectedItem }) => { if (selectedItem) { ms.addSelectedItem(selectedItem); setInput(''); } },
getA11yStatusMessage: ({ isOpen, resultCount }) => isOpen ? `${resultCount} tags available` : '', // concise status
});
return (
<div>
<label {...cb.getLabelProps()}>Tags</label> {/* SC 1.3.1 */}
<ul aria-label="Selected tags" className="chips">
{ms.selectedItems.map((t, i) => (
<li key={t} {...ms.getSelectedItemProps({ selectedItem: t, index: i })}>
{t} <button type="button" aria-label={`Remove tag ${t}`} onClick={() => ms.removeSelectedItem(t)}>×</button>
</li>
))}
</ul>
<input {...cb.getInputProps(ms.getDropdownProps({ preventKeyAction: cb.isOpen }))} />
<ul {...cb.getMenuProps()}> {/* always rendered */}
{cb.isOpen && items.length === 0 && <li className="empty" aria-disabled="true">No matching tags</li>}
{cb.isOpen && items.map((t, index) => (
<li key={t} {...cb.getItemProps({ item: t, index })} className={cb.highlightedIndex === index ? 'active' : ''}>{t}</li>
))}
</ul>
</div>
);
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Behaviour | Expected announcement |
|---|---|---|
| Typing | Filters items; menu opens | “5 tags available” (Downshift status) |
| Down / Up | Highlight item | Item name, position |
| Enter | Select; added as chip | Chip appears; input cleared |
| Backspace in empty input | Focus or remove last selected item | Selected item name |
| Left / Right on chips | Move between selected items | Chip names |
| Escape | Close menu | “collapsed” |
Integration context
Permalink to "Integration context"Filter patterns are described in accessible filtering & faceted search, including the multi-select combobox for column filters. A component-based alternative is the Headless UI Combobox for filters. Result counts should go through the shared announcer as in announcement queues for competing messages.
Gotchas
Permalink to "Gotchas"Menu always rendered. The element with getMenuProps must exist even when closed, so aria-controls points at something.
Status duplication. If your app announces “42 invoices shown” after filtering, keep Downshift’s message about options only (“5 tags available”) so the two do not compete.
Async loading. Show “Loading tags…” as a disabled item and update Downshift’s status only when results arrive.
Worked example
Permalink to "Worked example"A logistics app’s region and tag filters used a hand-rolled autocomplete with no roles. Screen reader users could type but not hear options. Rebuilding on Downshift with all prop getters, a visible label, chips via useMultipleSelection, a concise options status and a separate grid announcement fixed the experience. Keyboard users gained Backspace-to-remove and arrow navigation across chips, which several sighted power users adopted as well.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Tab to the input; confirm label and role.
- Type; confirm the “N tags available” status and option announcements as you arrow.
- Select; confirm the chip appears and the input clears.
- Press Backspace in the empty input; confirm the last chip is focused or removed as designed.
- Confirm the grid’s result count is announced by the app, not duplicated by Downshift.
Wrapping it for product teams
Permalink to "Wrapping it for product teams"Package the hook wiring in an Autocomplete component with props for label, items, itemToString, onSelect, multiple and statusMessage. Product teams should never spread prop getters themselves; a missed getter is the most common Downshift bug.
Common mistakes
Permalink to "Common mistakes"- Missing prop getters on one element.
- Menu not rendered when closed.
- Duplicate status messages.
- Placeholder instead of a label.
Adoption checklist
Permalink to "Adoption checklist"- Build a design-system
Autocompleteon Downshift with all prop getters wired. - Configure concise status messages.
- Provide multi-select chips with remove buttons.
- Support async loading and empty states.
- Keep grid result announcements in the app’s announcer.
FAQ
Permalink to "FAQ"Does Downshift follow ARIA 1.2 combobox?
Recent versions implement the ARIA 1.2 combobox pattern. Check your version’s release notes when upgrading from older ones.
Can I disable Downshift's status messages?
Yes; return an empty string from getA11yStatusMessage, or customise it.
Is Downshift only for React?
Downshift’s hooks are for React; the vanilla patterns can guide other frameworks.
How do I show selected items accessibly?
As a labelled list of chips, each with a named remove button.
How do I handle thousands of options?
Filter server-side, show the top results, and announce “Showing 20 of 1,240 matches; keep typing to narrow”.
Can options include descriptions?
Yes; render them inside the option. Keep them short, because the whole option text is announced.
Should Enter submit the surrounding form?
Downshift prevents form submission on Enter when selecting; test inside forms to be sure.
How do I test Downshift components?
Downshift’s own tests rely on Testing Library; use role queries (getByRole(‘combobox’), getAllByRole(‘option’)) and keyboard events in your tests too.
Does Downshift handle IME composition?
It handles typical input; test with IME users if your product supports CJK languages, especially Enter during composition.
Should the selected chips come before or after the input?
Before the input in DOM order is common, so users reach existing selections first; keep the order consistent across filters.
Related
Permalink to "Related"- Headless UI Combobox for filters — component-based alternative
- Multi-select combobox for column filters — the pattern
- Announcing filter result counts — grid messages