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-live status element and announces result counts; configure the message with getA11yStatusMessage (or disable it if you announce elsewhere).
  • Relevant criteria: SC 1.3.1, 4.1.2 (A), SC 4.1.3 (AA).
Hand-rolled autocomplete versus Downshift prop getters Comparison: a hand-rolled autocomplete often misses combobox roles, active descendant and status messages; Downshift's prop getters add them consistently while leaving markup to you. Hand-rolled autocomplete versus Downshift prop getters✗ Hand-rolledInput without combobox roleActive option not announcedMenu unlabelledNo result countCustom key handling bugs✓ Downshift prop gettersrole combobox via getInputPropsaria-activedescendant managedlabelled by getLabelPropsbuilt-in status message (configurable)tested keyboard behaviour

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.

Wiring Downshift for a filter Flow: label with getLabelProps; input with getInputProps; menu with getMenuProps always rendered; items with getItemProps; multiple selection with useMultipleSelection; status message configured; grid count announced separately. Wiring Downshift for a filterLabelgetLabelPropsvisible labelInputgetInputPropscombobox role and active descendantMenugetMenuProps on a always-rendered listlistbox target existsItemsgetItemPropsoption with aria-selectedSelectionsuseMultipleSelectionchips with remove buttonsMessagesgetA11yStatusMessage; grid countone source per message

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”
Downshift hooks and their uses in data UIs Matrix of hooks — useCombobox, useSelect, useMultipleSelection — against use cases, roles produced and typical filters. Downshift hooks and their uses in data UIsUse caseRolesTypical filteruseComboboxTyping to narrowcombobox + listboxCustomer, accountuseSelectChoosing without typingcombobox-like button +listboxStatus, perioduseMultipleSelectionSeveral valuesWorks with eitherTags, regions
Pick the hook by whether users type and how many values they choose.

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"
  1. Tab to the input; confirm label and role.
  2. Type; confirm the “N tags available” status and option announcements as you arrow.
  3. Select; confirm the chip appears and the input clears.
  4. Press Backspace in the empty input; confirm the last chip is focused or removed as designed.
  5. 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"
  1. Build a design-system Autocomplete on Downshift with all prop getters wired.
  2. Configure concise status messages.
  3. Provide multi-select chips with remove buttons.
  4. Support async loading and empty states.
  5. 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.

Permalink to "Related"

← Back to Headless UI Primitives for Data