Developer workspace showing an accessible dropdown component on screen

Downshift: The Headless Hero Your Dropdowns Deserve

The Orange Cat
The Orange Cat

If you have ever tried to build a custom dropdown, combobox, or autocomplete input from scratch, you know how quickly things spiral. First it is just a list that opens and closes. Then you need keyboard navigation. Then ARIA attributes. Then focus management, screen reader announcements, scroll-into-view behavior, and a dozen other details that separate a polished component from one that frustrates users. Downshift exists so you never have to solve those problems again. It gives you hooks that manage all the stateful logic and accessibility plumbing, while you remain in full control of the markup and styling.

Why Downshift Stands Out

Downshift is not a component library. It does not ship a single styled element. Instead, it provides a set of React hooks that return prop getters -- functions you spread onto your own elements to wire up event handlers, ARIA attributes, and state-driven behavior. This headless approach means you can use it with Tailwind, CSS Modules, styled-components, or any other styling solution without fighting against opinionated defaults.

Here is what makes it compelling:

  • WAI-ARIA 1.2 compliance baked into every hook, covering combobox, listbox, and select patterns out of the box
  • Keyboard navigation including arrow keys, Home, End, Escape, and Enter handling
  • Screen reader announcements via live regions that communicate selection changes
  • State reducer pattern that lets you intercept and override any internal state change
  • Tree-shakeable and side-effect free, so you only pay for the hooks you import
  • Preact and React Native support through dedicated entry points

Setting Up Shop

Install downshift with your package manager of choice:

npm install downshift
# or
yarn add downshift

That is it. There are no CSS files to import and no theme providers to wrap around your app. You bring the markup, Downshift brings the brains.

Your First Combobox

Searching Through a List

The useCombobox hook is the workhorse for autocomplete and search-as-you-type interfaces. It manages the input value, the open/closed state of the menu, highlighted index tracking, and item selection. You just tell it what your items are and how to filter them.

import { useState } from 'react';
import { useCombobox } from 'downshift';

const fruits = ['Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Mango', 'Orange', 'Peach', 'Pear', 'Strawberry'];

function FruitSearch() {
  const [filteredItems, setFilteredItems] = useState(fruits);

  const {
    isOpen,
    getInputProps,
    getItemProps,
    getLabelProps,
    getMenuProps,
    highlightedIndex,
    selectedItem,
  } = useCombobox({
    items: filteredItems,
    onInputValueChange: ({ inputValue }) => {
      setFilteredItems(
        fruits.filter((fruit) =>
          fruit.toLowerCase().includes(inputValue.toLowerCase())
        )
      );
    },
  });

  return (
    <div>
      <label {...getLabelProps()}>Pick a fruit:</label>
      <input
        {...getInputProps()}
        placeholder="Start typing..."
      />
      <ul
        {...getMenuProps()}
        style={{ listStyle: 'none', padding: 0, margin: 0 }}
      >
        {isOpen &&
          filteredItems.map((item, index) => (
            <li
              key={item}
              {...getItemProps({ item, index })}
              style={{
                padding: '8px 12px',
                backgroundColor:
                  highlightedIndex === index ? '#e0e7ff' : 'white',
                fontWeight: selectedItem === item ? 'bold' : 'normal',
              }}
            >
              {item}
            </li>
          ))}
      </ul>
    </div>
  );
}

Notice how every element gets its props from a getter function. getLabelProps links the label to the input via htmlFor. getInputProps attaches keyboard handlers, ARIA roles, and the current input value. getMenuProps sets up the listbox role and manages visibility. You never have to think about which aria- attribute goes where.

A Custom Select Without the Native Quirks

When you need a styled select dropdown that does not rely on the browser's native <select> element, useSelect is your friend. It handles toggle behavior, keyboard navigation through options, and proper ARIA roles for a listbox pattern.

import { useSelect } from 'downshift';

const colors = ['Red', 'Green', 'Blue', 'Yellow', 'Purple', 'Orange'];

function ColorPicker() {
  const {
    isOpen,
    selectedItem,
    getToggleButtonProps,
    getLabelProps,
    getMenuProps,
    getItemProps,
    highlightedIndex,
  } = useSelect({ items: colors });

  return (
    <div>
      <label {...getLabelProps()}>Favorite color:</label>
      <button
        {...getToggleButtonProps()}
        style={{
          padding: '8px 16px',
          border: '1px solid #ccc',
          borderRadius: '4px',
          background: 'white',
          cursor: 'pointer',
          minWidth: '200px',
          textAlign: 'left',
        }}
      >
        {selectedItem ?? 'Choose a color'}
      </button>
      <ul
        {...getMenuProps()}
        style={{
          listStyle: 'none',
          padding: 0,
          margin: 0,
          border: isOpen ? '1px solid #ccc' : 'none',
          maxHeight: '200px',
          overflowY: 'auto',
        }}
      >
        {isOpen &&
          colors.map((color, index) => (
            <li
              key={color}
              {...getItemProps({ item: color, index })}
              style={{
                padding: '8px 12px',
                backgroundColor:
                  highlightedIndex === index ? '#f3f4f6' : 'white',
                fontWeight: selectedItem === color ? 'bold' : 'normal',
                cursor: 'pointer',
              }}
            >
              {color}
            </li>
          ))}
      </ul>
    </div>
  );
}

The button toggles the menu on click (following the ARIA 1.2 recommendation), arrow keys move through options, Enter selects, and Escape closes. All of that comes free from useSelect.

Power Moves

Bending State to Your Will

The state reducer pattern is one of the most elegant features in Downshift. Every time an internal state change happens, it passes through a reducer function you can override. This lets you customize behavior without forking the library or reaching for hacky workarounds.

For example, suppose you want the menu to stay open after the user selects an item so they can pick multiple options in sequence:

import { useCombobox } from 'downshift';

function stateReducer(
  state: ReturnType<typeof useCombobox>['state'],
  actionAndChanges: Parameters<NonNullable<Parameters<typeof useCombobox>[0]['stateReducer']>>[1]
) {
  const { type, changes } = actionAndChanges;

  switch (type) {
    case useCombobox.stateChangeTypes.InputKeyDownEnter:
    case useCombobox.stateChangeTypes.ItemClick:
      return {
        ...changes,
        isOpen: true,
        highlightedIndex: state.highlightedIndex,
        inputValue: '',
      };
    default:
      return changes;
  }
}

function MultiPickCombobox({ items }: { items: string[] }) {
  const [selected, setSelected] = useState<string[]>([]);

  const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } =
    useCombobox({
      items,
      stateReducer,
      onSelectedItemChange: ({ selectedItem }) => {
        if (selectedItem && !selected.includes(selectedItem)) {
          setSelected((prev) => [...prev, selectedItem]);
        }
      },
    });

  return (
    <div>
      <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: '8px' }}>
        {selected.map((item) => (
          <span
            key={item}
            style={{
              padding: '2px 8px',
              borderRadius: '12px',
              background: '#dbeafe',
              fontSize: '14px',
            }}
          >
            {item}
          </span>
        ))}
      </div>
      <input {...getInputProps()} placeholder="Add items..." />
      <ul {...getMenuProps()} style={{ listStyle: 'none', padding: 0 }}>
        {isOpen &&
          items.map((item, index) => (
            <li
              key={item}
              {...getItemProps({ item, index })}
              style={{
                padding: '8px',
                backgroundColor: highlightedIndex === index ? '#e0e7ff' : 'white',
                opacity: selected.includes(item) ? 0.5 : 1,
              }}
            >
              {item}
            </li>
          ))}
      </ul>
    </div>
  );
}

The state reducer intercepts ItemClick and InputKeyDownEnter actions, keeps the menu open, clears the input, and preserves the highlighted index. Everything else falls through to the default behavior unchanged.

Working with Complex Objects

Real-world data is rarely an array of strings. When your items are objects, you need to tell Downshift how to convert them to strings for the input value and accessibility announcements. The itemToString prop handles this cleanly.

import { useCombobox } from 'downshift';

interface User {
  id: number;
  name: string;
  email: string;
  department: string;
}

const users: User[] = [
  { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
  { id: 2, name: 'Bob Martinez', email: 'bob@example.com', department: 'Design' },
  { id: 3, name: 'Carol Chen', email: 'carol@example.com', department: 'Product' },
  { id: 4, name: 'Dave Wilson', email: 'dave@example.com', department: 'Engineering' },
];

function UserSearch() {
  const [filteredUsers, setFilteredUsers] = useState(users);

  const {
    isOpen,
    getInputProps,
    getMenuProps,
    getItemProps,
    getLabelProps,
    highlightedIndex,
    selectedItem,
  } = useCombobox({
    items: filteredUsers,
    itemToString: (item) => (item ? item.name : ''),
    onInputValueChange: ({ inputValue }) => {
      setFilteredUsers(
        users.filter(
          (user) =>
            user.name.toLowerCase().includes(inputValue.toLowerCase()) ||
            user.department.toLowerCase().includes(inputValue.toLowerCase())
        )
      );
    },
  });

  return (
    <div>
      <label {...getLabelProps()}>Find a team member:</label>
      <input {...getInputProps()} placeholder="Search by name or department..." />
      <ul {...getMenuProps()} style={{ listStyle: 'none', padding: 0 }}>
        {isOpen &&
          filteredUsers.map((user, index) => (
            <li
              key={user.id}
              {...getItemProps({ item: user, index })}
              style={{
                padding: '10px 12px',
                backgroundColor: highlightedIndex === index ? '#f0fdf4' : 'white',
                borderLeft: selectedItem?.id === user.id ? '3px solid #22c55e' : '3px solid transparent',
              }}
            >
              <div style={{ fontWeight: 600 }}>{user.name}</div>
              <div style={{ fontSize: '12px', color: '#6b7280' }}>
                {user.department} - {user.email}
              </div>
            </li>
          ))}
      </ul>
    </div>
  );
}

The itemToString function determines what shows up in the input when an item is selected and what screen readers announce. The filtering logic searches across both name and department fields, giving users flexible search capabilities.

Disabling Items Gracefully

Version 9 introduced the isItemDisabled prop, replacing the older pattern of passing disabled directly to getItemProps. This centralizes the disabled logic at the hook level, so Downshift can properly skip disabled items during keyboard navigation.

const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({
  items: allItems,
  isItemDisabled: (item, _index) => item.outOfStock,
  itemToString: (item) => (item ? item.name : ''),
});

Disabled items are automatically skipped when the user presses arrow keys, and they receive the appropriate aria-disabled attribute. No extra wiring needed.

Conclusion

Downshift occupies a sweet spot in the React ecosystem. It is not the easiest library to reach for if you want a dropdown that works in five minutes with zero configuration -- that is what pre-styled component libraries are for. But if you are building a design system, need pixel-perfect control over your UI, or simply refuse to compromise on accessibility, Downshift is hard to beat. Its hooks handle the genuinely difficult parts (ARIA compliance, keyboard interactions, focus management, screen reader announcements) and get out of your way for everything else. With nearly a million weekly downloads, active maintenance since 2017, and full ARIA 1.2 support, it has earned its place as the go-to headless solution for dropdowns and comboboxes in React.