Skip to main content

react-select vs cmdk vs Downshift: Accessible Select/Combobox 2026

·PkgPulse Team

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-selectisMulti, 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 loadingloadOptions with debounced API fetch built in
  • cmdk has fuzzy filtering — built-in search scoring, custom filter function support
  • Downshift has three primitivesuseSelect, 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

Featurereact-selectcmdkDownshift
Styled✅ Out of boxNo (bring CSS)❌ Headless
Multi-selectisMultiNouseMultipleSelection
Async loadingAsyncSelectNo (custom)Custom
Creatable optionsCreatableSelectVia handlerCustom
Fuzzy searchBasic (startsWith)✅ Built-inCustom
Command paletteNo✅ Purpose-builtCan build
Keyboard nav✅ WAI-ARIA 1.2
Screen reader✅ Best-in-class
Grouped options✅ Groups✅ Custom
Virtual scrollNoNoCustom
Bundle size~20kB~2kB~7kB
npm weekly10M3M3M
GitHub stars27k10k12k
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 ⌘K experience
  • 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.