Skip to content
Drivn
5 min read

Next.js Combobox for the App Router

Add a searchable Next.js combobox with cmdk — single and multi-select, tag chips, and grouped items. Drivn ships it as one client-island file, no Radix.

A combobox is a select box with a search field baked in — click to open, type to filter a long list, and pick one or several values. It is the right control whenever a plain <select> has too many options to scan, which in practice covers framework pickers, country fields, assignee menus, and tag inputs. Building one in Next.js means handling fuzzy filtering, keyboard navigation, an outside-click dismissal, and a floating panel that still works inside a page that mostly renders on the server.

Drivn's Combobox wraps cmdk — the same search-list primitive behind the Command menu — in a single compound component you own. After npx drivn add combobox the source lives in src/components/ui/combobox.tsx, marked 'use client', with cmdk and lucide-react as its only runtime dependencies. Dot notation exposes every piece through one import: Combobox.Trigger, Combobox.Content, Combobox.Item, Combobox.Group, Combobox.Label, Combobox.Empty, and Combobox.Separator. Single-select, multi-select with tag chips, a clearable trigger, item icons, and grouped sections are all driven through props.

This guide installs Drivn in a Next.js 16 project, renders the Combobox as a controlled client island, switches on multi-select, and groups searchable items. Every snippet comes from the component's real API. For the full reference see the Combobox docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Combobox.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes the component source directly into your repository — there is no runtime npm package to version-lock. Open a terminal at the root of your Next.js 16 project and run npx drivn add combobox. The CLI prompts once for your install directory (defaulting to src/components/ui/), copies combobox.tsx, and adds its two dependencies to your package.json if they are missing: cmdk for the search list and keyboard navigation, and lucide-react for the chevron, search, check, and clear icons. The CLI reference documents every flag, including targeting a custom path or installing several components at once. After install you own the file — future Drivn releases will not overwrite it, and you can edit the internals without forking a package. Commit it to keep a clean baseline before customizing.

1# from the root of your Next.js 16 project
2npx drivn add combobox

Render as a controlled client island

An App Router page is a server component by default, so it cannot call useState. Drivn's Combobox is marked 'use client' because it wraps cmdk, which tracks the search input and the open state on the client. The clean pattern is to keep the picker and its value in a small client component, then render that island inside an otherwise server-rendered page. Import Combobox from @/components/ui/combobox, hold the selected value in useState, and bind value and onChange. Compose Combobox.Trigger, Combobox.Content, Combobox.Empty, and a list of Combobox.Item. cmdk filters the items against the search field automatically — there is no manual filter callback. The trigger's children render the chosen label, so a common pattern is frameworks.find(f => f.value === value)?.label. The installation guide covers project bootstrapping.

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: 'Svelte', value: 'svelte' },
9 { label: 'Next.js', value: 'nextjs' },
10]
11
12export function FrameworkPicker() {
13 const [value, setValue] = useState('')
14 return (
15 <Combobox value={value} onChange={setValue}>
16 <Combobox.Trigger placeholder="Select framework...">
17 {frameworks.find((f) => f.value === value)?.label}
18 </Combobox.Trigger>
19 <Combobox.Content placeholder="Search frameworks...">
20 <Combobox.Empty />
21 {frameworks.map((f) => (
22 <Combobox.Item key={f.value} value={f.value}>
23 {f.label}
24 </Combobox.Item>
25 ))}
26 </Combobox.Content>
27 </Combobox>
28 )
29}

Multi-select with tag chips

Pass the multiple prop and the Combobox switches to multi-select: the value becomes a string[], and each selected value renders as a removable tag chip inside the trigger. Selecting an item that is already chosen toggles it off, so the same click both adds and removes from the array. Each chip carries an X icon from lucide-react whose onMouseDown calls e.stopPropagation() and e.preventDefault() before deselecting — that stops the click from also toggling the panel. Hold the value as a string[] in useState and the rest of the API is unchanged. The relevant slice of the trigger, copied verbatim from the source, is below.

1// multi-select tag chips — verbatim from src/components/ui/combobox.tsx
2{values.map((v) => (
3 <span key={v} className={styles.tag.base}>
4 {v}
5 <X
6 className={styles.tag.remove}
7 onMouseDown={(e) => {
8 e.stopPropagation()
9 e.preventDefault()
10 onSelect(v)
11 }}
12 />
13 </span>
14))}

Search filtering and grouped items

The search field lives inside Combobox.Content, which mounts a cmdk instance with shouldFilter enabled, so typing scores every Combobox.Item against the query and hides non-matching rows without any state of your own. Wrap related items in Combobox.Group with a heading prop to render a labeled section, and drop Combobox.Separator between groups for a thin divider. The highlighted row uses data-[selected=true]:bg-accent from the styles object, so cmdk's keyboard navigation paints the active item as you arrow through the list. Add Combobox.Empty to control the no-results message, and pass an icon prop on any Combobox.Item — a component reference like icon={CircleDot} — to render an icon before the label. See the Combobox examples for the full set of patterns.

1'use client'
2import { useState } from 'react'
3import { Combobox } from '@/components/ui/combobox'
4import { CircleDot, Timer, CheckCircle2 } from 'lucide-react'
5
6export function StatusPicker() {
7 const [value, setValue] = useState('')
8 return (
9 <Combobox value={value} onChange={setValue}>
10 <Combobox.Trigger placeholder="Select status...">
11 {value}
12 </Combobox.Trigger>
13 <Combobox.Content placeholder="Search statuses...">
14 <Combobox.Empty />
15 <Combobox.Group heading="Active">
16 <Combobox.Item value="todo" icon={CircleDot}>
17 Todo
18 </Combobox.Item>
19 <Combobox.Item value="in-progress" icon={Timer}>
20 In Progress
21 </Combobox.Item>
22 </Combobox.Group>
23 <Combobox.Separator />
24 <Combobox.Group heading="Closed">
25 <Combobox.Item value="done" icon={CheckCircle2}>
26 Done
27 </Combobox.Item>
28 </Combobox.Group>
29 </Combobox.Content>
30 </Combobox>
31 )
32}

How the panel opens and closes

Unlike most comboboxes, the Drivn version does not pull in a popover primitive. The trigger is a plain <button> whose onClick toggles the open state, and the content sits in an absolutely positioned panel directly beneath it. Dismissal is handled by one effect in the source: a mousedown listener on document closes the panel when the click lands outside the component ref. The panel animates with transition-[opacity,scale] and toggles pointer-events-none while hidden, applying open ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none' so it never intercepts clicks when closed. The chevron in the trigger rotates 180 degrees when open via open && 'rotate-180', or swaps for a clearable X when you pass the clearable prop and a value is set. The dismissal effect is below, copied from combobox.tsx.

1// outside-click dismissal — verbatim from src/components/ui/combobox.tsx
2React.useEffect(() => {
3 const onClick = (e: MouseEvent) => {
4 if (!ref.current?.contains(e.target as Node))
5 close()
6 }
7 document.addEventListener('mousedown', onClick)
8 return () =>
9 document.removeEventListener('mousedown', onClick)
10}, [close])
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

The Combobox itself is a client component — it is marked 'use client' because it wraps cmdk, which manages the search input and selection with hooks, and it tracks the open state with useState. Your surrounding page and layout still render on the server. The usual pattern is a small client component holding the picker plus its value, rendered as an island inside an otherwise server-rendered route. Next.js inserts the client boundary at the import automatically.

Pass the multiple prop. The value then becomes a string[] instead of a string, each chosen value renders as a removable tag chip in the trigger, and selecting an already-chosen item toggles it off rather than replacing the selection. In single mode the Combobox closes the panel after a pick; in multiple mode it stays open so you can select several values in a row. Hold the state as a string[] in useState.

Yes, automatically. Combobox.Content mounts a cmdk instance with shouldFilter enabled, so cmdk scores each Combobox.Item against the current search value and hides non-matching rows. The highlighted item is tracked with a data-[selected=true] attribute the styles target, and arrow keys move the selection. You provide the items as children; the filtering, keyboard navigation, and empty-state toggle are handled by the primitive underneath.

No. The Combobox manages its own open state and positions the dropdown in an absolutely positioned panel below the trigger. A single useEffect adds a mousedown listener to document and closes the panel when the click lands outside the component ref. There is no Radix Popover and no floating-ui — the entire open/close behavior lives in the one file the CLI writes to your repo, alongside the cmdk search list and lucide-react icons.