Skip to content
Drivn logoDrivn
5 min read

Drivn vs shadcn/ui — Combobox Component Compared

Drivn vs shadcn/ui React Combobox: Drivn ships a real compound component with built-in multi-select — shadcn documents a Popover + Command composition recipe.

shadcn/ui does not ship a Combobox component. The shadcn docs site has a Combobox page, but the page is a composition recipe — copy the Popover primitive, copy the Command primitive, glue them together with useState for the open state, and wire the trigger button by hand. Drivn takes the opposite path. The Combobox component in Drivn is a single compound primitive — Combobox, Combobox.Trigger, Combobox.Content, Combobox.Item — with the open state, click-outside dismissal, and selection logic already wired inside the component file.

The API surface diverges in three places: the import shape, multi-select support, and the dialog/popover wrapper. Drivn exposes one import and dot notation: import { Combobox } from "@/components/ui/combobox" then <Combobox.Trigger>, <Combobox.Content>, <Combobox.Item>. shadcn requires import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" plus import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command" plus a useState(false) hook for open plus a useState("") for the selected value plus a button you style yourself. The Drivn version replaces all of that with three tags inside one root.

This page walks through every surface where the two diverge: the API shape, the multi-select pattern, the click-outside behavior, and the open state handling. Every snippet below compiles against the Combobox source the Drivn CLI writes into your repo. If you have a shadcn Popover + Command Combobox today, the migration is collapsing the two-import composition into one Drivn Combobox import and removing the manual useState(false) for the popover open state — the Drivn root holds it via context.

Side-by-side comparison

FeatureDrivnshadcn/ui
Ships as single componentComposition recipe (Popover + Command)
Underlying primitivecmdkcmdk + @radix-ui/react-popover
Imports needed1 (Combobox)2 (Popover + Command)
API shapeDot notation (Combobox.Trigger)Flat (PopoverTrigger + CommandInput)
Built-in multi-selectmultiple prop with tag chips
Open stateInternal — useContextExternal useState(false)
Click-outside dismissalBuilt in via mousedown listenerVia Radix Popover
Clearable triggerclearable propHand-rolled X button
Runtime UI depscmdk + lucide-reactcmdk + @radix-ui/react-popover + lucide-react
LicenseMITMIT

API side-by-side

shadcn's recommended Combobox is a long composition. You import Popover, PopoverTrigger, PopoverContent, Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem — nine names from two files. You hold an open boolean in useState(false) and pass it to Popover as a controlled prop. You hold a value string in useState("") and update it inside the CommandItem onSelect handler. You style the trigger as a <Button variant="outline" role="combobox" aria-expanded={open}> and add a ChevronsUpDown icon yourself.

Drivn collapses all of that into one tag tree. The Combobox source holds the open state inside a context provider, exposes Combobox.Trigger as the click target with the chevron and clearable X already in place, and renders Combobox.Content as an absolute-positioned panel that animates with transition-[opacity,scale]. You pass value and onChange to the root and the rest is handled internally.

1// shadcn/ui — composition recipe
2'use client'
3import { useState } from 'react'
4import { Check, ChevronsUpDown } from 'lucide-react'
5import { Button } from '@/components/ui/button'
6import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
7import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
8
9export function FrameworkCombobox() {
10 const [open, setOpen] = useState(false)
11 const [value, setValue] = useState('')
12
13 return (
14 <Popover open={open} onOpenChange={setOpen}>
15 <PopoverTrigger asChild>
16 <Button variant="outline" role="combobox" aria-expanded={open}>
17 {value || 'Select framework...'}
18 <ChevronsUpDown className="ml-2 h-4 w-4" />
19 </Button>
20 </PopoverTrigger>
21 <PopoverContent>
22 <Command>
23 <CommandInput placeholder="Search framework..." />
24 <CommandList>
25 <CommandEmpty>No framework found.</CommandEmpty>
26 <CommandGroup>
27 <CommandItem onSelect={(v) => { setValue(v); setOpen(false) }}>React</CommandItem>
28 </CommandGroup>
29 </CommandList>
30 </Command>
31 </PopoverContent>
32 </Popover>
33 )
34}
35
36// Drivn — single component
37'use client'
38import { useState } from 'react'
39import { Combobox } from '@/components/ui/combobox'
40
41export function FrameworkCombobox() {
42 const [value, setValue] = useState('')
43
44 return (
45 <Combobox value={value} onChange={setValue}>
46 <Combobox.Trigger placeholder="Select framework...">{value}</Combobox.Trigger>
47 <Combobox.Content placeholder="Search framework...">
48 <Combobox.Empty />
49 <Combobox.Item value="react">React</Combobox.Item>
50 </Combobox.Content>
51 </Combobox>
52 )
53}

Multi-select with tag chips

shadcn's Combobox recipe is single-select only. The composition page does not document a multi-select pattern — the official advice is to fork the recipe, swap value: string for value: string[], render the selected values as tag chips above the trigger, and write the toggle logic by hand. Drivn ships multi-select as a built-in prop. Pass multiple to the root and the Combobox source switches the trigger from rendering a single string to rendering tag chips, each with an <X /> button that removes the item via the same onSelect handler that adds it.

The internal logic is in the root. When multiple is set, onSelect reads the current array, toggles the value via arr.includes(v) ? arr.filter(i => i !== v) : [...arr, v], and forwards the new array to onChange. The trigger reads Array.isArray(value) ? value : [] and renders one <span className={styles.tag.base}> per entry. Click on the X inside a tag and the same onSelect removes it. No external state, no separate add/remove handlers — one onChange covers both directions.

1// Drivn — multi-select with multiple prop
2'use client'
3import { useState } from 'react'
4import { Combobox } from '@/components/ui/combobox'
5
6export function FrameworksCombobox() {
7 const [value, setValue] = useState<string[]>([])
8
9 return (
10 <Combobox multiple value={value} onChange={(v) => setValue(v as string[])}>
11 <Combobox.Trigger placeholder="Select frameworks..." />
12 <Combobox.Content placeholder="Search frameworks...">
13 <Combobox.Empty />
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.Content>
19 </Combobox>
20 )
21}

Open state and click-outside

shadcn delegates open state and click-outside dismissal to Radix Popover. You pass open and onOpenChange to <Popover>, hold the boolean in your component, and Radix handles the listener that closes the popover when the user clicks outside or presses Escape. The pattern works, but the open state lives in your component file even though no other code in your component reads it. Drivn moves the boolean inside the component. The root calls useState(false) and exposes open and setOpen via context to Combobox.Trigger and Combobox.Content.

Click-outside dismissal is also internal. The Combobox source attaches a mousedown listener on document inside useEffect and closes the dropdown when the click target is not inside the root ref. The cleanup function removes the listener on unmount. The whole behavior is about ten lines of plain React — no Radix Popper, no portal, no positioning library. The dropdown uses absolute top-full left-0 right-0 to position itself directly under the trigger.

1// Drivn — internal click-outside, verbatim from the Combobox source
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])

Bundle cost and customization

shadcn's Combobox composition pulls in cmdk for the search list and @radix-ui/react-popover for the trigger, panel, and click-outside behavior. Radix Popover ships its own @floating-ui/react-dom dependency for positioning. Drivn drops Radix entirely — the dropdown uses CSS absolute positioning and a custom mousedown listener, so the runtime dependencies are cmdk for the search list and lucide-react for the chevron, X, and check icons. One fewer dependency in your bundle, one fewer release cadence to track.

Customization also flips. With shadcn, changing the trigger style means restyling the <Button> import — but the trigger is shared with every other place in your app that renders a button. With Drivn, the trigger is the Combobox source itself: open the file, edit styles.trigger.base, save. The change applies to every Combobox without affecting other buttons. See the Combobox examples page for the full set of patterns: groups, clearable, icons, disabled items, async loading.

1// Drivn — styles object lives in the Combobox source you own
2const styles = {
3 base: 'relative',
4 trigger: {
5 base: cn(
6 'flex items-center justify-between w-full min-h-10',
7 'px-3 gap-2',
8 'border border-input rounded-[10px] text-sm',
9 'focus:outline-none transition-colors',
10 'cursor-pointer'
11 ),
12 // ... edit this object to restyle every Combobox in your app
13 },
14}
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 shadcn docs site has a Combobox page but the page documents a composition recipe — combine the Popover primitive with the Command primitive, hold the open state in useState, hold the selected value in another useState, and style the trigger button as a custom outline button with a chevron icon. The recipe is about 40 lines of glue code that you copy into your repo. Drivn ships a single Combobox compound component instead.

The Drivn Combobox accepts a multiple prop on the root. When set, the internal onSelect callback toggles values in a string array — arr.includes(v) ? arr.filter(i => i !== v) : [...arr, v] — and the trigger switches from rendering a single string to rendering tag chips, each with an X icon that removes the item. The whole pattern lives in the component source. shadcn's composition recipe is single-select only.

Because the Combobox dropdown only needs three things: position the panel under the trigger, animate it on open, and dismiss on outside click. The Drivn Combobox source uses CSS absolute top-full for positioning, transition-[opacity,scale] for the animation, and a mousedown listener inside useEffect for the dismissal. About ten lines for the listener — no floating-ui dependency, no portal, no extra package.

Yes. The Drivn Combobox wraps the same cmdk primitive shadcn does, so Combobox.Group (with a heading prop) and Combobox.Separator are exposed via dot notation on the root. The fuzzy search filtering, keyboard navigation, and ARIA roles all come from cmdk and behave identically. The difference is only in the wrapper around cmdk, not the search behavior itself.