downshift
Downshift supplies headless React state and accessibility behavior for autocompletes, editable comboboxes, custom selects, and removable tag groups. Its hooks return state, actions, and prop-getter functions; you render the label, input or button, menu, options, tags, and styling, then spread each getter onto the correct element. useCombobox, useSelect, and useTagGroup follow current ARIA-oriented patterns. The older Downshift render-prop component remains exported but the README recommends hooks and says that component will eventually be removed.
Downshift remains one of the best choices for teams that want to own every pixel and DOM node while starting from serious interaction logic. Choose a native control or a more structured component library when your team does not want to assemble and continuously accessibility-test the widget itself.
Use it if
- A React design system needs complete control over markup and styling while reusing tested keyboard and screen-reader behavior
- You need an editable autocomplete through useCombobox, a button-driven custom select through useSelect, or removable chips through useTagGroup
- Complex interaction rules need controlled state, action callbacks, or a pure stateReducer rather than a fixed component API
- Items are objects, disabled options or changing object identities matter, and you can supply itemToString and itemToKey consistently
- A native select or datalist meets the product requirement: browser controls bring less code, less ARIA responsibility, and fewer interaction states to test
- You want a finished visual component: Downshift renders no menu or styles for the hooks, so positioning, portals, loading states, empty states, animation, and design tokens remain your work
- The team cannot commit to accessibility testing: omitting a prop getter, applying it to the wrong element, conditionally unmounting required markup, or overriding its handlers can break the intended keyboard and screen-reader behavior
- You are not using React or Preact: the package's main API is React hooks and its npm peer dependency requires React 16.12 or newer
- You want a low-churn API across majors: hooks have separate version 7, 8, and 9 migration guides; version 9 removed selectedItemChanged and default aria-live status messages, and the legacy Downshift component is marked for eventual removal
Setup reality
Install downshift plus React; React 16.12 or newer is the declared peer range. Version 9.4.0 ships ESM, CommonJS, React Native, and TypeScript declarations, and marks the package side-effect-free for bundlers. The install is easy, but the first correct component is not. You must keep the menu element mounted, spread getLabelProps, getInputProps or getToggleButtonProps, getMenuProps, and getItemProps onto the intended elements, pass every rendered item and its index, and supply a null-safe itemToString. Object items whose references change need itemToKey, also null-safe. Downshift manages interaction state, not filtering, remote requests, list positioning, collision detection, virtualization, styling, or result fetching. Async autocomplete needs your own stale-response or AbortController logic. Getter functions merge user handlers; pass handlers into the getter instead of spreading the getter and then overwriting onKeyDown or onBlur. Conditional rendering can trigger ref warnings and can remove elements screen readers expect; suppressRefError exists for exceptional layouts, not as a default fix. Version 9 removed selectedItemChanged in favor of itemToKey, removed getA11ySelectionMessage and getA11yRemovalMessage, and no longer creates default aria-live status text for hooks. The generated ARIA markup may be sufficient, but localized or product-specific announcements now require getA11yStatusMessage or your own live region. Controlled props require updating them in the corresponding callback or the UI appears frozen. Shadow roots and iframes need an environment object with the right document and event listener methods. Test mouse, touch, Escape, Home and End, arrow keys, typeahead, blur, disabled items, focus return, and at least the screen readers your users rely on.
Patterns
Wire an accessible editable comboboxbuild-basic-combobox
import {useCombobox} from 'downshift';
function FruitCombobox({items}) {
const {isOpen, highlightedIndex, getLabelProps, getInputProps, getMenuProps, getItemProps} = useCombobox({items});
return <>
<label {...getLabelProps()}>Fruit</label>
<input {...getInputProps()} />
<ul {...getMenuProps()}>
{isOpen && items.map((item, index) => (
<li key={item} {...getItemProps({item, index})} data-highlighted={highlightedIndex === index}>
{item}
</li>
))}
</ul>
</>;
}Keep the menu mounted and apply every getter to its intended element. Downshift supplies behavior and ARIA props, not CSS or positioning.
Filter suggestions as input changesfilter-combobox-items
const [shown, setShown] = useState(allItems);
const combobox = useCombobox({
items: shown,
onInputValueChange({inputValue}) {
const query = inputValue.toLowerCase();
setShown(allItems.filter(item => item.toLowerCase().includes(query)));
},
});Filtering is your responsibility. For remote searches, cancel stale requests or compare request IDs before replacing the current items.
Give object items stable identity and textuse-object-items
const combobox = useCombobox({
items: users,
itemToString: user => user?.name ?? '',
itemToKey: user => user?.id,
onSelectedItemChange: ({selectedItem}) => saveUserId(selectedItem?.id ?? null),
});Both conversion callbacks receive null. itemToKey replaces the selectedItemChanged prop removed in version 9.
Control selection from application statecontrol-selected-item
const [selectedItem, setSelectedItem] = useState(null);
const combobox = useCombobox({
items,
selectedItem,
onSelectedItemChange({selectedItem: next}) {
setSelectedItem(next);
},
});Once selectedItem is controlled, update it in the callback. Passing a value without updating it makes user selection appear not to work.
Skip unavailable optionsdisable-options
const combobox = useCombobox({
items: plans,
itemToString: plan => plan?.name ?? '',
isItemDisabled: plan => plan.soldOut,
});
// Still pass every item and its real index to getItemProps.Disabled items are skipped by keyboard navigation and cannot be selected. Render a visible disabled treatment as well.
Change selection behavior with a state reducerkeep-menu-open
const combobox = useCombobox({
items,
stateReducer(state, {type, changes}) {
if (type === useCombobox.stateChangeTypes.ItemClick ||
type === useCombobox.stateChangeTypes.InputKeyDownEnter) {
return {...changes, isOpen: true, highlightedIndex: state.highlightedIndex};
}
return changes;
},
});Keep reducers pure. Version 9 passes action details plus a nested changes object; older examples may use a different shape.
Build a button-driven selectbuild-custom-select
const {isOpen, selectedItem, highlightedIndex, getLabelProps, getToggleButtonProps, getMenuProps, getItemProps} = useSelect({items});
return <>
<label {...getLabelProps()}>Plan</label>
<button type='button' {...getToggleButtonProps()}>{selectedItem ?? 'Choose a plan'}</button>
<ul {...getMenuProps()}>
{isOpen && items.map((item, index) => (
<li key={item} {...getItemProps({item, index})} data-highlighted={highlightedIndex === index}>{item}</li>
))}
</ul>
</>;Use useSelect for a non-editable choice widget. If a native select satisfies the design, it remains the simpler accessibility baseline.
Create removable tags with roving focusbuild-tag-group
const {items, activeIndex, getTagGroupProps, getTagProps, getTagRemoveProps} = useTagGroup({
initialItems: ['React', 'TypeScript'],
});
return <div {...getTagGroupProps({'aria-label': 'Skills'})}>
{items.map((item, index) => (
<span key={item} {...getTagProps({index, 'aria-label': item})}>
{item}
<button type='button' {...getTagRemoveProps({index, 'aria-label': `Remove ${item}`})}>x</button>
</span>
))}
</div>;useTagGroup was added in the 9.x line. Its getters implement tag focus and removal behavior; visual focus must still be styled clearly.
Add an event handler through a prop gettermerge-custom-handler
<input
{...getInputProps({
onBlur(event) {
analytics.track('combobox-blur');
},
})}
/>Pass handlers into the getter so Downshift can merge them. A later onBlur prop would overwrite the handler returned by getInputProps.
Provide version 9 aria-live status textannounce-custom-status
const combobox = useCombobox({
items,
getA11yStatusMessage({isOpen, highlightedIndex}) {
if (!isOpen) return '';
if (highlightedIndex >= 0) return `${items[highlightedIndex].name} highlighted`;
return `${items.length} results available`;
},
});Version 9 hooks have no default status-message function and now pass only hook state. Compute counts and highlighted items from your own items array.
Clear a selection with a separate buttonclear-combobox-selection
const {selectItem, setInputValue} = useCombobox({items});
<button type='button' onClick={() => {
selectItem(null);
setInputValue('');
}}>
Clear
</button>Give the button an accessible name in icon-only UIs and decide explicitly where focus should remain after clearing.
Use the correct window environmentrender-in-iframe
const frameWindow = iframeRef.current?.contentWindow;
const combobox = useCombobox({
items,
environment: frameWindow ?? window,
});Iframes and shadow roots need document and add/removeEventListener access from the rendered context. Wait until the target environment exists before showing the widget.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-aria-components | npm | You want accessible React primitives with a more structured component API across many widget types |
| @headlessui/react | npm | You use React or Tailwind and prefer headless components with more prescribed markup and transitions |
| @radix-ui/react-select | npm | You specifically need a composable custom select with popper positioning and portal pieces included |
| cmdk | npm | The UI is a command palette or searchable command menu rather than a general form combobox |