React Combobox Component Examples
Drop-in React Combobox examples: searchable single-select, multi-select with tag chips, grouped options, clearable trigger, item icons, async loading.
Reach for a combobox the moment a plain <Select> starts scrolling. Fifteen options is a select; two hundred countries, every user in a workspace, or a tag list that grows as the team uses it needs a text input in front of the list. The Drivn Combobox is exactly that — a trigger, a search field, and a filtered list — shipped as one compound primitive: Combobox, Combobox.Trigger, Combobox.Content, Combobox.Item, plus Combobox.Group, Combobox.Separator, Combobox.Empty, and Combobox.Label. One import, dot notation, no Radix Popover in the dependency tree.
cmdk powers the search list underneath, and the Drivn component passes shouldFilter to it, so typing narrows the options against each item's value with no filter callback of your own. Everything else — open state, click-outside dismissal via a mousedown listener, single versus multiple selection — lives in the roughly 250 lines of TSX that npx drivn add combobox writes to @/components/ui/combobox. You own that file afterward, which is why every call site below stays flat.
Seven shapes follow: a basic single-select, multi-select with tag chips, grouped options with separators, a clearable trigger, items with icons, async loading from a fetch, and a custom empty state. Swap the sample items for your own data and each one compiles unchanged in a Next.js or Vite project. To see the API next to shadcn/ui, read Drivn vs shadcn Combobox, or start with the Drivn CLI.
Basic searchable single-select
The minimum Combobox is a root with value and onChange, a Combobox.Trigger for the click target, and a Combobox.Content that holds the search input and filtered items. The Combobox source sets shouldFilter on the underlying cmdk primitive so the input filters the items by their value attribute automatically — no manual filter callback. When the user picks an item, the root closes the dropdown and forwards the value to onChange.
The trigger renders the selected value as children if you pass it (typical pattern: look the value up in your data array and render the label). When nothing is selected, the trigger shows the placeholder prop in muted text. The chevron rotates 180 degrees when the dropdown is open via open && 'rotate-180' on the chevron icon — verbatim from the Combobox source. Click anywhere outside the component dismisses the dropdown via the internal mousedown listener.
1 'use client' 2 import { useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 const frameworks = [ 6 { label: "React", value: "react" }, 7 { label: "Vue", value: "vue" }, 8 { label: "Angular", value: "angular" }, 9 { label: "Svelte", value: "svelte" }, 10 { label: "Next.js", value: "nextjs" }, 11 ] 12 13 export default function Page() { 14 const [value, setValue] = useState("") 15 16 return ( 17 <Combobox value={value} onChange={(v) => setValue(v as string)}> 18 <Combobox.Trigger placeholder="Select framework..."> 19 {frameworks.find(f => f.value === value)?.label} 20 </Combobox.Trigger> 21 <Combobox.Content placeholder="Search frameworks..."> 22 <Combobox.Empty /> 23 {frameworks.map(f => ( 24 <Combobox.Item key={f.value} value={f.value}> 25 {f.label} 26 </Combobox.Item> 27 ))} 28 </Combobox.Content> 29 </Combobox> 30 ) 31 }
Multi-select with tag chips
Add multiple to the root and the trigger stops rendering a single label and starts rendering one chip per selection. Each chip is a <span className={styles.tag.base}> — inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-md bg-muted text-foreground — holding the value text and an <X /> that calls the same onSelect(v) used to add it, so selection toggles in both directions from one handler. The types follow the prop: value becomes string[] and onChange receives the whole new array on every toggle, so seed the state with useState<string[]>([]) and cast in the handler.
The detail worth copying is the X handler. It runs on onMouseDown rather than onClick, and it calls e.stopPropagation() then e.preventDefault() — without both, the event reaches the trigger button and reopens the dropdown you were trying to leave shut. The Combobox source uses the identical pattern for the clearable X. Because the chips inherit bg-muted and text-xs, a multi-select drops into an existing form beside Input and Select with no extra styling.
1 'use client' 2 import { useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 export default function Page() { 6 const [value, setValue] = useState<string[]>([]) 7 8 return ( 9 <Combobox multiple value={value} onChange={(v) => setValue(v as string[])}> 10 <Combobox.Trigger placeholder="Select frameworks..." /> 11 <Combobox.Content placeholder="Search frameworks..."> 12 <Combobox.Empty /> 13 <Combobox.Item value="react">React</Combobox.Item> 14 <Combobox.Item value="vue">Vue</Combobox.Item> 15 <Combobox.Item value="angular">Angular</Combobox.Item> 16 <Combobox.Item value="svelte">Svelte</Combobox.Item> 17 <Combobox.Item value="nextjs">Next.js</Combobox.Item> 18 </Combobox.Content> 19 </Combobox> 20 ) 21 }
Grouped options with separators
Wrap related items in Combobox.Group with a heading prop to render a category label above the items. Drop Combobox.Separator between groups for a thin horizontal line. Both compound subcomponents proxy to the cmdk primitives — the Combobox source renders <CommandPrimitive.Group heading={heading}> for the group and <CommandPrimitive.Separator> for the divider — so cmdk's built-in keyboard navigation jumps across groups correctly without any extra wiring.
Grouping is the right shape when items have a meaningful taxonomy: frontend frameworks vs backend frameworks, status types by phase, files by directory. The heading uses text-xs font-medium text-muted-foreground via the [&_[cmdk-group-heading]] arbitrary selector in the styles object, which lets cmdk's automatic cmdk-group-heading data attribute do the styling without you wiring it on the heading element directly.
1 'use client' 2 import { useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 export default function Page() { 6 const [value, setValue] = useState("") 7 8 return ( 9 <Combobox value={value} onChange={(v) => setValue(v as string)}> 10 <Combobox.Trigger placeholder="Select framework...">{value}</Combobox.Trigger> 11 <Combobox.Content placeholder="Search frameworks..."> 12 <Combobox.Empty /> 13 <Combobox.Group heading="Frontend"> 14 <Combobox.Item value="react">React</Combobox.Item> 15 <Combobox.Item value="vue">Vue</Combobox.Item> 16 <Combobox.Item value="angular">Angular</Combobox.Item> 17 <Combobox.Item value="svelte">Svelte</Combobox.Item> 18 </Combobox.Group> 19 <Combobox.Separator /> 20 <Combobox.Group heading="Backend"> 21 <Combobox.Item value="php">PHP</Combobox.Item> 22 <Combobox.Item value="symfony">Symfony</Combobox.Item> 23 <Combobox.Item value="laravel">Laravel</Combobox.Item> 24 </Combobox.Group> 25 </Combobox.Content> 26 </Combobox> 27 ) 28 }
Clearable trigger
Pass clearable to Combobox.Trigger and the chevron icon swaps for an X button when the field has a value. Click the X and the Combobox source calls onClear from context, which forwards an empty value to the root's onChange — '' for single mode, [] for multi mode. The X uses onMouseDown with stopPropagation so the click does not also toggle the dropdown open.
The clearable pattern is right when the field is optional and the user might want to deselect after picking. Search filters, optional metadata fields, "any" selectors that default to no choice. For required fields, leave clearable off and let the trigger always show the chevron — the user changes their mind by reopening the dropdown and picking a different item.
1 'use client' 2 import { useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 export default function Page() { 6 const [value, setValue] = useState("") 7 8 return ( 9 <Combobox value={value} onChange={(v) => setValue(v as string)}> 10 <Combobox.Trigger placeholder="Select framework..." clearable> 11 {value} 12 </Combobox.Trigger> 13 <Combobox.Content> 14 <Combobox.Empty /> 15 <Combobox.Item value="react">React</Combobox.Item> 16 <Combobox.Item value="vue">Vue</Combobox.Item> 17 <Combobox.Item value="angular">Angular</Combobox.Item> 18 <Combobox.Item value="svelte">Svelte</Combobox.Item> 19 </Combobox.Content> 20 </Combobox> 21 ) 22 }
Items with icons
Pass an icon prop to Combobox.Item to render an icon before the label. The prop accepts either a component reference (icon={Circle}) or a JSX element — the Combobox source detects with React.isValidElement(Icon) ? Icon : <Icon className="w-4 h-4" /> so both shapes work. Status pickers, file-type selectors, and category lists all use this — the icon makes scanning the open dropdown faster than reading text alone.
Lucide icons compose particularly well because they accept className and inherit currentColor. Render Circle for backlog, CircleDot for todo, Timer for in-progress, CheckCircle2 for done, and the visual hierarchy matches the workflow without any color coding. Combine with Combobox.Group to group statuses by category — open vs closed, blocked vs unblocked.
1 'use client' 2 import { useState } from "react" 3 import { Circle, CircleDot, Timer, CheckCircle2 } from "lucide-react" 4 import { Combobox } from "@/components/ui/combobox" 5 6 export default function Page() { 7 const [value, setValue] = useState("") 8 9 return ( 10 <Combobox value={value} onChange={(v) => setValue(v as string)}> 11 <Combobox.Trigger placeholder="Select status...">{value}</Combobox.Trigger> 12 <Combobox.Content placeholder="Search statuses..."> 13 <Combobox.Empty /> 14 <Combobox.Item value="backlog" icon={Circle}> 15 Backlog 16 </Combobox.Item> 17 <Combobox.Item value="todo" icon={CircleDot}> 18 Todo 19 </Combobox.Item> 20 <Combobox.Item value="in-progress" icon={Timer}> 21 In Progress 22 </Combobox.Item> 23 <Combobox.Item value="done" icon={CheckCircle2}> 24 Done 25 </Combobox.Item> 26 </Combobox.Content> 27 </Combobox> 28 ) 29 }
Async loading from fetch
For data fetched from an API, hold the items in useState and load them inside useEffect once the dropdown opens. The pattern is plain React — the Combobox itself does not need to know the data is async. While the request is in flight, render a single Combobox.Item with disabled and a "Loading..." label so the dropdown shows progress. When the request resolves, set the array and the items render naturally.
For debounced search against a remote API, lift the input value out of cmdk by holding a separate useState("") and pass it through a debounce hook. Use the debounced value as the dependency of an useEffect that fires the fetch. The Combobox source re-mounts the cmdk primitive on every open via a key={open ? 'open' : 'closed'} prop, so the search input clears each time the dropdown opens — no leftover query from a previous session.
1 'use client' 2 import { useEffect, useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 type User = { id: string; name: string } 6 7 export default function Page() { 8 const [value, setValue] = useState("") 9 const [users, setUsers] = useState<User[]>([]) 10 const [loading, setLoading] = useState(false) 11 12 useEffect(() => { 13 setLoading(true) 14 fetch('/api/users') 15 .then(r => r.json()) 16 .then((data: User[]) => setUsers(data)) 17 .finally(() => setLoading(false)) 18 }, []) 19 20 return ( 21 <Combobox value={value} onChange={(v) => setValue(v as string)}> 22 <Combobox.Trigger placeholder="Assign to..."> 23 {users.find(u => u.id === value)?.name} 24 </Combobox.Trigger> 25 <Combobox.Content placeholder="Search users..."> 26 {loading ? ( 27 <Combobox.Item value="loading" disabled> 28 Loading... 29 </Combobox.Item> 30 ) : ( 31 <> 32 <Combobox.Empty /> 33 {users.map(u => ( 34 <Combobox.Item key={u.id} value={u.id}> 35 {u.name} 36 </Combobox.Item> 37 ))} 38 </> 39 )} 40 </Combobox.Content> 41 </Combobox> 42 ) 43 }
Custom empty state
Pass children to Combobox.Empty to override the default "No results found." message. The component wraps cmdk's <CommandPrimitive.Empty>, which renders only when the search input has a value and no items match the filter. For most apps a tailored empty message is the right shape — "No frameworks match your search", "No users found, try a different name". Keep it short and tied to the data domain.
For an empty state that links to a creation flow ("Can't find your tag? Create one"), wrap the empty children in a styled <button> and call your create handler in onClick. The cmdk primitive renders the empty area as a regular block element, so any JSX you pass through is positioned inside the dropdown panel below the input. See the full Combobox API for every prop available on the subcomponents.
1 'use client' 2 import { useState } from "react" 3 import { Combobox } from "@/components/ui/combobox" 4 5 export default function Page() { 6 const [value, setValue] = useState("") 7 8 return ( 9 <Combobox value={value} onChange={(v) => setValue(v as string)}> 10 <Combobox.Trigger placeholder="Select framework..." /> 11 <Combobox.Content> 12 <Combobox.Empty> 13 No frameworks match your search. 14 </Combobox.Empty> 15 <Combobox.Item value="react">React</Combobox.Item> 16 <Combobox.Item value="vue">Vue</Combobox.Item> 17 </Combobox.Content> 18 </Combobox> 19 ) 20 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
No. The Combobox imports cmdk for the search list and lucide-react for the chevron, X, and check icons. Positioning uses CSS absolute top-full left-0 right-0 directly under the trigger, click-outside dismissal uses a custom mousedown listener inside useEffect, and the open animation uses Tailwind's transition-[opacity,scale] utility. No @radix-ui/react-popover, no floating-ui, no portal — about 250 lines of TSX you own.
Pass the label as children to Combobox.Trigger. The component renders children ?? value when a value is selected, so look the id up in your data array and pass the label — <Combobox.Trigger>{frameworks.find(f => f.value === value)?.label}</Combobox.Trigger>. For multi-select, the trigger automatically renders tag chips from the value array, so children are not used in multi mode.
Yes. Pass disabled to Combobox.Item and the Combobox source forwards the prop to the cmdk primitive. cmdk applies data-disabled="true" to the rendered element, which the styles object picks up via data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 so the item dims and rejects clicks. The keyboard navigation also skips disabled items automatically.
In multi-select mode, each Combobox.Item reads the value array from context and computes isSelected = (value as string[]).includes(itemValue). When selected, a <Check /> icon from lucide-react renders at the right edge of the row using text-primary ml-auto. The toggle logic is in the root's onSelect callback — it adds the value if missing, removes it if present, and forwards the new array to your onChange handler.
The Combobox source sets a key={open ? 'open' : 'closed'} prop on the <CommandPrimitive> wrapper inside Combobox.Content. React re-mounts the cmdk primitive whenever the key changes, which resets the search input to empty. This matches the expected behavior of dropdowns that reopen with a fresh state — users do not want their previous query lingering when they reopen the field for a different selection.

