react-select vs cmdk vs Downshift: Accessible Select/Combobox 2026
TL;DR
Building accessible select and combobox components is notoriously tricky — keyboard navigation, ARIA roles, screen reader announcements, async search, and multi-value inputs all have accessibility gotchas that native <select> elements don't handle well. react-select is the battle-tested workhorse — styled out of the box, multi-select, async loading, creatable options, grouped options; it handles 90% of select use cases with minimal code but carries a notable bundle (~20kB gzipped). cmdk is the command palette component popularized by Vercel, Raycast, and Linear — an accessible, keyboard-driven command menu/combobox used for fuzzy search across actions, it powers the ⌘K pattern everywhere in 2026. Downshift is the headless primitive for building any select/combobox/autocomplete with full accessibility — unstyled, low-level hooks that give you complete control, used internally by Chakra, Mantine, and others. For multi-select dropdowns with async search and built-in styling: react-select. For command palette / ⌘K / fuzzy search: cmdk. For building a fully custom, branded select/combobox from scratch with correct ARIA: Downshift.
Key Takeaways
- react-select handles multi-select —
isMulti, remove tags, creatable options built in - cmdk is the ⌘K standard — powers command palettes in Linear, Vercel, Raycast, shadcn/ui
- Downshift is headless — hooks only, zero UI, WAI-ARIA 1.2 compliant patterns
- react-select has async loading —
loadOptionswith debounced API fetch built in - cmdk has fuzzy filtering — built-in search scoring, custom filter function support
- Downshift has three primitives —
useSelect,useCombobox,useMultipleSelection - cmdk is 2kB — smallest of the three, used in shadcn/ui's Command component
Use Case Map
Single select dropdown → react-select (styled) or Downshift (headless)
Multi-select with tags → react-select (isMulti)
Async search (API-backed) → react-select (loadOptions) or Downshift
Command palette / ⌘K → cmdk
Application-wide fuzzy search → cmdk
Custom branded select → Downshift (full control)
Grouped options → react-select or Downshift
Creatable options ("Add X") → react-select (Creatable) or Downshift
Autocomplete input → Downshift useCombobox or react-select
react-select: Batteries-Included
react-select is the most-installed React select library — styled, feature-complete, and used across enterprise applications.
Installation
npm install react-select
Basic Select
import Select from "react-select";
interface Option {
value: string;
label: string;
}
const options: Option[] = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "angular", label: "Angular" },
{ value: "solid", label: "SolidJS" },
];
export function FrameworkSelect() {
return (
<Select
options={options}
placeholder="Select a framework..."
isClearable
onChange={(option) => console.log(option?.value)}
/>
);
}
Multi-Select with Tags
import Select from "react-select";
const tagOptions = [
{ value: "typescript", label: "TypeScript" },
{ value: "react", label: "React" },
{ value: "node", label: "Node.js" },
{ value: "postgres", label: "PostgreSQL" },
{ value: "docker", label: "Docker" },
];
export function TagInput({ onChange }: { onChange: (tags: string[]) => void }) {
return (
<Select
options={tagOptions}
isMulti
placeholder="Select technologies..."
onChange={(selected) => onChange(selected.map((s) => s.value))}
closeMenuOnSelect={false} // Keep open when selecting multiple
classNamePrefix="react-select" // For CSS targeting
/>
);
}
Async Loading
import AsyncSelect from "react-select/async";
interface UserOption {
value: string;
label: string;
email: string;
}
async function searchUsers(inputValue: string): Promise<UserOption[]> {
if (!inputValue) return [];
const response = await fetch(`/api/users/search?q=${encodeURIComponent(inputValue)}`);
const users = await response.json();
return users.map((user: { id: string; name: string; email: string }) => ({
value: user.id,
label: user.name,
email: user.email,
}));
}
export function UserSearch({ onSelect }: { onSelect: (userId: string) => void }) {
return (
<AsyncSelect
loadOptions={searchUsers}
defaultOptions={false} // Don't load until user types
placeholder="Search users..."
noOptionsMessage={({ inputValue }) =>
inputValue ? `No users found for "${inputValue}"` : "Start typing to search"
}
onChange={(option) => option && onSelect(option.value)}
// Custom option rendering
formatOptionLabel={(option) => (
<div>
<strong>{option.label}</strong>
<span style={{ color: "#666", marginLeft: 8 }}>{option.email}</span>
</div>
)}
/>
);
}
Creatable Select
import CreatableSelect from "react-select/creatable";
import { useState } from "react";
interface TagOption {
value: string;
label: string;
__isNew__?: boolean;
}
export function CreatableTagInput() {
const [options, setOptions] = useState<TagOption[]>([
{ value: "bug", label: "bug" },
{ value: "feature", label: "feature" },
{ value: "documentation", label: "documentation" },
]);
const [selected, setSelected] = useState<TagOption[]>([]);
function handleCreate(inputValue: string) {
const newOption: TagOption = { value: inputValue, label: inputValue };
setOptions((prev) => [...prev, newOption]);
setSelected((prev) => [...prev, newOption]);
}
return (
<CreatableSelect
isMulti
options={options}
value={selected}
onChange={(selected) => setSelected([...selected])}
onCreateOption={handleCreate}
placeholder="Add labels..."
formatCreateLabel={(inputValue) => `Create label: "${inputValue}"`}
/>
);
}
Custom Styling with CSS Modules
import Select, { StylesConfig } from "react-select";
interface Option { value: string; label: string; }
const customStyles: StylesConfig<Option, false> = {
control: (provided, state) => ({
...provided,
backgroundColor: "#1a1a1a",
borderColor: state.isFocused ? "#6366f1" : "#333",
boxShadow: state.isFocused ? "0 0 0 2px rgba(99, 102, 241, 0.3)" : "none",
"&:hover": { borderColor: "#6366f1" },
borderRadius: 8,
minHeight: 40,
}),
menu: (provided) => ({
...provided,
backgroundColor: "#1a1a1a",
border: "1px solid #333",
borderRadius: 8,
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected
? "#6366f1"
: state.isFocused
? "#2d2d2d"
: "transparent",
color: "#e5e7eb",
"&:hover": { backgroundColor: "#2d2d2d" },
}),
singleValue: (provided) => ({ ...provided, color: "#e5e7eb" }),
input: (provided) => ({ ...provided, color: "#e5e7eb" }),
placeholder: (provided) => ({ ...provided, color: "#6b7280" }),
};
export function StyledSelect() {
return (
<Select
styles={customStyles}
options={[{ value: "a", label: "Option A" }, { value: "b", label: "Option B" }]}
/>
);
}
cmdk: Command Palette / ⌘K
cmdk is the accessible command menu component that powers ⌘K patterns. It's used in shadcn/ui's Command component.
Installation
npm install cmdk
Basic Command Palette
import { Command } from "cmdk";
import { useState } from "react";
import "./command.css"; // Your styles
export function CommandPalette() {
const [value, setValue] = useState("");
return (
<Command label="Command Menu">
<Command.Input
placeholder="Search commands, pages, users..."
value={value}
onValueChange={setValue}
/>
<Command.List>
<Command.Empty>No results found for "{value}"</Command.Empty>
<Command.Group heading="Navigation">
<Command.Item onSelect={() => (window.location.href = "/dashboard")}>
Dashboard
</Command.Item>
<Command.Item onSelect={() => (window.location.href = "/settings")}>
Settings
</Command.Item>
<Command.Item onSelect={() => (window.location.href = "/profile")}>
Profile
</Command.Item>
</Command.Group>
<Command.Group heading="Actions">
<Command.Item onSelect={() => console.log("New project")}>
Create new project
</Command.Item>
<Command.Item onSelect={() => console.log("Invite team")}>
Invite team member
</Command.Item>
</Command.Group>
<Command.Separator />
<Command.Group heading="Help">
<Command.Item onSelect={() => console.log("Docs")}>
Open documentation
</Command.Item>
</Command.Group>
</Command.List>
</Command>
);
}
Dialog Command Palette (⌘K Modal)
import { Command } from "cmdk";
import { useState, useEffect } from "react";
export function CommandDialog() {
const [open, setOpen] = useState(false);
// Open with ⌘K or Ctrl+K
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
if (!open) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "15vh",
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 640,
maxHeight: "60vh",
background: "#1a1a1a",
borderRadius: 12,
border: "1px solid #333",
boxShadow: "0 25px 50px rgba(0,0,0,0.5)",
overflow: "hidden",
}}
>
<Command>
<Command.Input
placeholder="Type a command or search..."
style={{
width: "100%",
padding: "16px",
background: "transparent",
border: "none",
borderBottom: "1px solid #333",
color: "#e5e7eb",
fontSize: 16,
outline: "none",
}}
/>
<Command.List style={{ maxHeight: "calc(60vh - 56px)", overflowY: "auto" }}>
<Command.Empty style={{ padding: 16, color: "#6b7280" }}>
No results found.
</Command.Empty>
<Command.Group heading="Recent">
<Command.Item onSelect={() => setOpen(false)}>
Open PkgPulse dashboard
</Command.Item>
</Command.Group>
</Command.List>
</Command>
</div>
</div>
);
}
shadcn/ui Command Component
// This is exactly how shadcn/ui wraps cmdk:
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"; // shadcn/ui wrapper
export function ShadcnCommand() {
return (
<Command className="rounded-lg border shadow-md">
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>
Calendar
</CommandItem>
<CommandItem>
Search Emoji
</CommandItem>
<CommandItem>
Calculator
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>
Profile
<CommandShortcut>⌘P</CommandShortcut>
</CommandItem>
<CommandItem>
Billing
<CommandShortcut>⌘B</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}
Downshift: Headless Primitive
Downshift provides WAI-ARIA compliant hooks for building any select or combobox — zero styling, maximum control.
Installation
npm install downshift
useSelect Hook
import { useSelect } from "downshift";
interface Option { value: string; label: string; }
const options: Option[] = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
];
export function CustomSelect() {
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
highlightedIndex,
getItemProps,
} = useSelect({
items: options,
itemToString: (item) => item?.label ?? "",
onSelectedItemChange: ({ selectedItem }) => {
console.log("Selected:", selectedItem?.value);
},
});
return (
<div style={{ position: "relative" }}>
<label {...getLabelProps()}>Framework</label>
<button
{...getToggleButtonProps()}
style={{
width: 200,
padding: "8px 12px",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: 6,
color: "#e5e7eb",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
}}
>
{selectedItem?.label ?? "Select framework..."}
<span>{isOpen ? "▲" : "▼"}</span>
</button>
<ul
{...getMenuProps()}
style={{
display: isOpen ? "block" : "none",
position: "absolute",
top: "100%",
width: "100%",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: 6,
listStyle: "none",
padding: 4,
margin: 0,
zIndex: 100,
}}
>
{isOpen &&
options.map((option, index) => (
<li
key={option.value}
{...getItemProps({ item: option, index })}
style={{
padding: "8px 12px",
borderRadius: 4,
background: highlightedIndex === index ? "#2d2d2d" : "transparent",
cursor: "pointer",
color: "#e5e7eb",
}}
>
{option.label}
</li>
))}
</ul>
</div>
);
}
useCombobox Hook (Autocomplete)
import { useCombobox } from "downshift";
import { useState } from "react";
const allItems = ["TypeScript", "JavaScript", "Python", "Rust", "Go", "Swift", "Kotlin"];
export function AutocompleteInput() {
const [inputItems, setInputItems] = useState(allItems);
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
items: inputItems,
onInputValueChange: ({ inputValue }) => {
setInputItems(
allItems.filter((item) =>
item.toLowerCase().startsWith((inputValue ?? "").toLowerCase())
)
);
},
});
return (
<div>
<label {...getLabelProps()}>Language</label>
<div style={{ display: "flex" }}>
<input
{...getInputProps()}
style={{
padding: "8px 12px",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: 6,
color: "#e5e7eb",
width: 200,
}}
/>
<button
{...getToggleButtonProps()}
aria-label="toggle menu"
style={{ marginLeft: 4, padding: "8px 12px", cursor: "pointer" }}
>
{isOpen ? "▲" : "▼"}
</button>
</div>
<ul
{...getMenuProps()}
style={{
display: isOpen && inputItems.length ? "block" : "none",
position: "absolute",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: 6,
listStyle: "none",
padding: 4,
margin: 0,
zIndex: 100,
width: 200,
}}
>
{isOpen &&
inputItems.map((item, index) => (
<li
key={item}
{...getItemProps({ item, index })}
style={{
padding: "8px 12px",
background: highlightedIndex === index ? "#2d2d2d" : "transparent",
fontWeight: selectedItem === item ? "bold" : "normal",
cursor: "pointer",
color: "#e5e7eb",
borderRadius: 4,
}}
>
{item}
</li>
))}
</ul>
</div>
);
}
useMultipleSelection Hook
import { useMultipleSelection, useCombobox } from "downshift";
const allTags = ["react", "typescript", "node", "postgres", "redis", "docker", "kubernetes"];
export function MultiTagInput() {
const [inputValue, setInputValue] = useState("");
const availableTags = allTags.filter((tag) => !selectedItems.includes(tag));
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
} = useMultipleSelection<string>({ initialSelectedItems: [] });
const filteredTags = availableTags.filter((tag) =>
tag.toLowerCase().includes(inputValue.toLowerCase())
);
const { isOpen, getMenuProps, getInputProps, getItemProps, highlightedIndex } =
useCombobox({
inputValue,
items: filteredTags,
onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
if (
type === useCombobox.stateChangeTypes.InputKeyDownEnter ||
type === useCombobox.stateChangeTypes.ItemClick
) {
if (newSelectedItem) {
addSelectedItem(newSelectedItem);
setInputValue("");
}
}
if (type === useCombobox.stateChangeTypes.InputChange) {
setInputValue(newInputValue ?? "");
}
},
});
return (
<div style={{ border: "1px solid #333", borderRadius: 8, padding: 8, background: "#1a1a1a" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 4 }}>
{selectedItems.map((item, index) => (
<span
key={item}
{...getSelectedItemProps({ selectedItem: item, index })}
style={{
background: "#2d2d2d",
color: "#e5e7eb",
padding: "2px 8px",
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
cursor: "default",
}}
>
{item}
<button
onClick={() => removeSelectedItem(item)}
style={{ background: "none", border: "none", cursor: "pointer", color: "#9ca3af" }}
>
×
</button>
</span>
))}
<input
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen }))}
placeholder="Add tags..."
style={{ background: "transparent", border: "none", color: "#e5e7eb", outline: "none" }}
/>
</div>
<ul
{...getMenuProps()}
style={{
display: isOpen && filteredTags.length ? "block" : "none",
listStyle: "none",
padding: 4,
margin: 0,
}}
>
{isOpen &&
filteredTags.map((item, index) => (
<li
key={item}
{...getItemProps({ item, index })}
style={{
padding: "6px 8px",
background: highlightedIndex === index ? "#2d2d2d" : "transparent",
borderRadius: 4,
cursor: "pointer",
color: "#e5e7eb",
}}
>
{item}
</li>
))}
</ul>
</div>
);
}
import { useState } from "react";
Feature Comparison
| Feature | react-select | cmdk | Downshift |
|---|---|---|---|
| Styled | ✅ Out of box | No (bring CSS) | ❌ Headless |
| Multi-select | ✅ isMulti | No | ✅ useMultipleSelection |
| Async loading | ✅ AsyncSelect | No (custom) | Custom |
| Creatable options | ✅ CreatableSelect | Via handler | Custom |
| Fuzzy search | Basic (startsWith) | ✅ Built-in | Custom |
| Command palette | No | ✅ Purpose-built | Can build |
| Keyboard nav | ✅ | ✅ | ✅ WAI-ARIA 1.2 |
| Screen reader | ✅ | ✅ | ✅ Best-in-class |
| Grouped options | ✅ | ✅ Groups | ✅ Custom |
| Virtual scroll | No | No | Custom |
| Bundle size | ~20kB | ~2kB | ~7kB |
| npm weekly | 10M | 3M | 3M |
| GitHub stars | 27k | 10k | 12k |
| React required | ✅ | ✅ | ✅ (React only) |
| TypeScript | ✅ | ✅ | ✅ |
Accessibility Deep Dive and Screen Reader Compatibility
Accessible select and combobox components are among the hardest UI primitives to implement correctly because the WAI-ARIA specification for combobox changed substantially between ARIA 1.1 and ARIA 1.2, and many implementations still follow the older pattern. Downshift implements the ARIA 1.2 combobox pattern, which is the current correct approach — it manages aria-expanded, aria-autocomplete, aria-activedescendant, and role="combobox" on the input element and role="listbox" on the dropdown. react-select uses a custom accessibility implementation that works well with NVDA on Windows and VoiceOver on macOS but has had historical issues with some combinations of screen reader and browser. cmdk uses the ARIA combobox pattern with role="combobox" on the input and role="option" on items, which means standard screen reader announcement patterns apply. Testing with actual screen readers — not just axe-core — is essential before shipping any custom select or combobox to production; the specification is complex enough that automated tools miss important behavioral requirements.
Performance at Scale: Virtual Scrolling and Large Option Lists
All three libraries struggle with option lists exceeding several thousand items because they render all options into the DOM simultaneously. react-select has no built-in virtualization — rendering 10,000 options creates 10,000 DOM nodes and scrolling becomes janky. The community-maintained react-window-select package wraps react-select with react-window for virtualization, but requires additional setup. Downshift is headless and leaves virtualization entirely to you — pair it with TanStack Virtual or react-window for lists over 1,000 items. cmdk handles medium-sized lists well because its filtering reduces visible items quickly, but if your command palette has 5,000+ commands, implementing virtual scrolling via TanStack Virtual inside the Command.List container is necessary. For async search scenarios (API-backed lookups), none of this matters — you debounce the input and only show 10-50 results at a time, keeping DOM footprint minimal regardless of the total dataset size.
TypeScript Integration and Generic Types
react-select is strongly typed through its generics — Select<Option, IsMulti, GroupBase> lets you specify the option type, whether multi-select is enabled, and the group structure. The type inference can be verbose, particularly when using StylesConfig or formatOptionLabel, where you must explicitly pass the generic parameters to avoid any escaping. Downshift's hook-based API is also generically typed: useSelect<Option> ensures all returned functions and state are typed to your option type. cmdk's Command.Item renders children without a typed option model, which is consistent with its design for arbitrary command actions rather than data-driven option lists. For projects using strict TypeScript, Downshift provides the best type coverage for custom data models, while react-select is satisfactory for standard option shapes. Avoid using react-select's any escape hatches in formatOptionLabel if you can express the option type explicitly.
Bundle Size Implications and Code Splitting
react-select's approximately 20KB gzipped includes Emotion for CSS-in-JS styles, which can surprise teams auditing their bundle. If your project doesn't already use Emotion, this dependency is bundled unnecessarily. The alternative is using the unstyled variant (react-select v5+) which removes the Emotion dependency and lets you style with CSS modules or Tailwind — reducing the bundle contribution significantly. cmdk at 2KB is negligible and contains no styling dependencies. Downshift at 7KB is also lean. For applications using shadcn/ui, the Command component (based on cmdk) and the Select component (based on Radix UI primitives, not Downshift) are already available — there's no reason to add react-select or Downshift if shadcn's components cover your use case. Code splitting these libraries is straightforward since they're typically used in specific feature pages; a dynamic import on the component that uses react-select keeps it out of the main bundle.
Migration from Legacy Solutions
Teams migrating from older select libraries (Select2, Chosen, jQuery UI autocomplete) to modern React options typically land on react-select as the safest migration target — it covers the same feature set with a React API and maintains the familiar dropdown UX that jQuery-era users expect. The defaultInputValue and inputValue props support both controlled and uncontrolled patterns, making incremental migration possible. Teams building new applications from scratch in 2026 typically choose based on whether they need a command palette (cmdk) or a traditional form select (react-select or Downshift). The combination that's emerged as idiomatic in shadcn/ui-based apps is: cmdk for the command palette, shadcn's Select (Radix) for simple single-select dropdowns, and react-select or custom Downshift for complex multi-select with async search. This covers the full range of select use cases without redundant dependencies.
When to Use Each
Choose react-select if:
- Need multi-select with tag/pill UI out of the box
- Async server-side search with debouncing
- Creatable options ("Add new option")
- Grouped options with headers
- Quick implementation with built-in styling (can override)
- Don't need command palette — need a traditional dropdown
Choose cmdk if:
- Building a command palette or
⌘Kexperience - Fuzzy search across commands, pages, or actions
- Application-wide keyboard-driven navigation
- Using shadcn/ui (it's based on cmdk)
- Want instant filter as user types across a large list of items
Choose Downshift if:
- Building a fully custom branded select/combobox
- Need fine-grained ARIA control
- Component library internals (what Chakra and Mantine do)
- Complex state (conditional disabled items, custom filtering)
- Want zero runtime CSS dependency — pure JS behavior hooks
- Multi-value combobox with complex tag input patterns
Methodology
Data sourced from react-select documentation (react-select.com), cmdk documentation (cmdk.paco.me), Downshift documentation (www.downshift-js.com), npm weekly download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the React Discord and WAI-ARIA specification.
Related: Floating UI vs Tippy.js vs Radix Tooltip for positioning the dropdown overlay, or Radix UI vs Headless UI vs Ariakit accessible components for broader accessible component primitives.
See also: React vs Vue and React vs Svelte