react-select vs cmdk vs Downshift: Accessible Select/Combobox 2026
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 | ✅ | ✅ | ✅ |
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.