Skip to content
Drivn logoDrivn
6 min read

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.

A combobox is the searchable dropdown — text input plus filtered list, used everywhere a plain <Select> runs out of room. Status pickers, framework selectors, country dropdowns, tag autocompletes, command-style inline search. The Drivn Combobox component wraps cmdk for the search list and ships as a single compound primitive: Combobox, Combobox.Trigger, Combobox.Content, Combobox.Item, plus Combobox.Group, Combobox.Separator, Combobox.Empty, Combobox.Label. One import, dot notation, no Radix Popover.

This page collects the patterns that come up in real apps. Each snippet imports from @/components/ui/combobox, the same path the Drivn CLI writes the file to, so paste them into a Next.js or Vite project after npx drivn add combobox and they compile without edits. The Combobox source itself lives in your repo after install — about 250 lines of TSX you own end to end. Click-outside dismissal, open state, and selection logic are all internal to the component, so the call sites stay flat.

The seven examples below cover the common shapes: a basic single-select, a multi-select with tag chips, grouped options with separators, a clearable trigger, items with icons, async loading from a fetch, and a controlled empty state. Pick the one closest to your use case and replace the items with whatever your app needs to expose. If you also want to compare the API against shadcn/ui, see Drivn vs shadcn Combobox.

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'
2import { useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5const 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
13export 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

Pass multiple to the root and the Combobox source switches the trigger to render tag chips for each selected value. Each chip is a <span className={styles.tag.base}> with the value text and an <X /> icon that removes the item via the same onSelect handler that adds it. The value prop becomes string[] and onChange receives the new array on every toggle. Use useState<string[]>([]) to seed the controlled state.

The chip click on the X uses onMouseDown with e.stopPropagation() and e.preventDefault() so the trigger button does not also receive the click and toggle the dropdown open. This is the same pattern used by the clearable X — verbatim from the source. The tags use bg-muted text-foreground and text-xs rounded-md, so they match the rest of the form vocabulary without extra styling.

1'use client'
2import { useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5export 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'
2import { useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5export 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'
2import { useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5export 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'
2import { useState } from "react"
3import { Circle, CircleDot, Timer, CheckCircle2 } from "lucide-react"
4import { Combobox } from "@/components/ui/combobox"
5
6export 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'
2import { useEffect, useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5type User = { id: string; name: string }
6
7export 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'
2import { useState } from "react"
3import { Combobox } from "@/components/ui/combobox"
4
5export 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}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

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.