Skip to content
Drivn
5 min read

Drivn vs shadcn/ui — Select Compared

Compare Drivn Select vs shadcn/ui — a context-driven dot-notation dropdown versus a Radix portal popper. Both copy-paste, one ships zero Radix deps.

The native <select> element is dependable and close to impossible to style, which is why every component library eventually ships a replacement for it. Drivn's Select and shadcn/ui's Select are two such replacements, and the foundations under them share almost nothing.

shadcn/ui delegates to @radix-ui/react-select. Its file re-exports roughly nine pieces — Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator, and the scroll buttons — each a thin shell around the matching Radix primitive. Radix owns everything underneath: a portal for the open menu, a popper engine that positions it, roving arrow-key focus, and typeahead matching.

Drivn keeps the whole component in @/components/ui/select.tsx, and its imports stop at React, the ChevronDown icon from lucide-react, and the local cn() utility. Four parts — the root plus Trigger, Menu, and Option — are joined with Object.assign, so dot notation is the only surface you learn. A React context passes the open flag and the current value between them, useState holds that flag, and a mousedown listener registered inside useEffect closes the menu when a click lands outside the root ref.

What that means in practice is a context-driven dropdown that stays in the document flow, set against a Radix portal popper. The sections ahead compare the dependency tree, the API shape, the open/close model, and the keyboard behaviour.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveReact context + native buttons@radix-ui/react-select
Runtime UI dependencieslucide-react icon + cn() utility@radix-ui/react-select
API shapeDot notation — Select.Trigger / Menu / OptionNine flat exports
Menu renderingAbsolute-positioned div, stays in the DOM treePortal + positioned popper
Open/close stateuseState in the Select rootRadix internal state machine
Keyboard navigationNot built in — add it in your copyArrow keys + typeahead included
Client componentYes — 'use client'Yes — 'use client'
LicenseMITMIT
Copy-paste install

The runtime footprint

shadcn/ui's Select is a layer over @radix-ui/react-select. The component file imports SelectPrimitive from the Radix package and re-exports its Root, Trigger, Value, Content, Item, Group, Label, and Separator parts, plus two scroll-button helpers. Radix ships the state machine, the portal, the popper positioning, and the keyboard model — all of that JavaScript lands in your bundle the moment you import the component.

Drivn's Select imports React, the ChevronDown icon from lucide-react, and the local cn() utility. Every class the component uses lives in the styles object below, reproduced verbatim from the registry. styles.trigger is the 40-pixel-tall bordered button, styles.menu is the absolutely positioned dropdown with max-h-[200px] overflow-y-auto, and styles.option is each row. There is no Radix package, no portal library, and no popper engine. For a button-triggered action list rather than a value picker, see the Dropdown component, which follows the same zero-dependency framing.

1// Drivn — styles object, no Radix (verbatim from registry)
2const styles = {
3 base: 'relative',
4 trigger: cn(
5 'flex items-center justify-between w-full h-10 px-4',
6 'border border-input rounded-[10px] text-sm',
7 'focus:outline-none transition-[color,box-shadow]',
8 'focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring',
9 'cursor-pointer'
10 ),
11 placeholder: 'text-muted-foreground',
12 chevron: cn(
13 'w-4 h-4 text-muted-foreground',
14 'transition-transform duration-200'
15 ),
16 menu: cn(
17 'absolute top-full left-0 right-0 mt-1 z-50',
18 'bg-card border border-border rounded-[10px] p-1',
19 'shadow-lg max-h-[200px] overflow-y-auto',
20 'transition-[opacity,scale] duration-150 ease-out'
21 ),
22 option: cn(
23 'flex items-center w-full px-3 py-2 text-sm rounded-lg',
24 'hover:bg-accent transition-colors cursor-pointer'
25 ),
26 selected: 'text-primary font-medium',
27}

Dot notation vs nine flat exports

shadcn/ui spreads the Select across nine named exports. A real menu needs Select, SelectTrigger, SelectValue, SelectContent, and SelectItem at minimum, so a single import line runs long and every consumer has to remember which flat name maps to which slot. Drivn binds its four parts with Object.assign(SelectRoot, { Trigger, Menu, Option }), so one import of Select gives you the whole compound and the JSX reads as a tree: Select.Trigger, Select.Menu, Select.Option.

The call site below is the public API, matching the Select docs page. You pass value and onChange to the root, a placeholder to the trigger, and a value to each option. The trigger's children render the label of the current selection — Drivn does not auto-resolve the label from the value, so you look it up yourself, which keeps the component free of an options-registry abstraction. For free-text filtering over the same list, reach for the Combobox instead.

1import { useState } from "react"
2import { Select } from "@/components/ui/select"
3
4const options = [
5 { label: "React", value: "react" },
6 { label: "Vue", value: "vue" },
7 { label: "Svelte", value: "svelte" },
8]
9
10export default function Page() {
11 const [value, setValue] = useState("")
12
13 return (
14 <Select value={value} onChange={setValue}>
15 <Select.Trigger placeholder="Pick a framework">
16 {options.find(o => o.value === value)?.label}
17 </Select.Trigger>
18 <Select.Menu>
19 {options.map(o => (
20 <Select.Option key={o.value} value={o.value}>
21 {o.label}
22 </Select.Option>
23 ))}
24 </Select.Menu>
25 </Select>
26 )
27}

How the menu opens and closes

shadcn/ui hands open/close, focus management, and dismissal to the Radix state machine. Drivn keeps the model in plain view inside SelectRoot. A useState flag holds open; the trigger flips it on click. Picking an option runs onSelect, which calls your onChange and then close(), so the menu dismisses itself on selection. An outside click is handled by a useEffect that registers a mousedown listener on document and closes the menu whenever the click target is not inside the component's ref.

The menu itself never leaves the DOM — Select.Menu always renders, and the open flag only toggles its classes between opacity-100 scale-100 and opacity-0 scale-95 pointer-events-none. That is why it animates with the transition-[opacity,scale] rule in styles.menu instead of mounting and unmounting. Because the menu is an absolutely positioned child rather than a portal, it inherits the stacking context of its parent — give the field room or a z-index if it sits inside a clipped container. The Dialog component shows the portal-based alternative when you do need to escape the parent.

1// Drivn — open/close logic, verbatim from registry
2const close = React.useCallback(() => setOpen(false), [])
3const onSelect = React.useCallback(
4 (v: string) => {
5 onChange?.(v)
6 close()
7 },
8 [onChange, close]
9)
10
11React.useEffect(() => {
12 const onClick = (e: MouseEvent) => {
13 if (!ref.current?.contains(e.target as Node)) close()
14 }
15 document.addEventListener('mousedown', onClick)
16 return () => document.removeEventListener('mousedown', onClick)
17}, [close])

The keyboard story

Keyboard behaviour is where the two flavours genuinely part ways, and it is worth being precise about the gap. Radix hands shadcn/ui a full listbox implementation: roving arrow-key focus between options, Home and End jumps, typeahead that matches as the user types, and the ARIA roles wired to match. Drivn ships none of that machinery. What it ships instead is native elements — the trigger is a <button type="button"> and so is every option — which means Tab reaches each one and Enter or Space activates it, the trigger toggling the menu and an option committing its value. The trigger also draws a real focus ring through focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring, so tabbing into the field is visible rather than silent.

For a status picker, a country field, or a sort control with six choices, Tab and Enter over a short list is enough, and the smaller component is the better trade. When it is not enough, the file is yours: a keydown handler on Select.Menu that moves focus across the option buttons is a contained edit, and it costs nothing in dependencies. The selected row already marks itself visually through styles.selected (text-primary font-medium), so focus movement is the only piece missing. When a surface is keyboard-first by design, the Command menu ships arrow-key navigation already wired.

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

Not out of the box. Drivn's Select is built from native button elements, so Tab and Enter operate the trigger and every option, but there is no roving arrow-key focus or typeahead between options. shadcn/ui inherits both from Radix. Because you own the Drivn file after install, you can add a keydown handler to Select.Menu to move focus across the option buttons.

No. Select.Menu is an absolutely positioned div that stays inside the component's own DOM tree, positioned with top-full so it sits directly below the trigger. shadcn/ui renders its content into a portal via Radix. The Drivn menu therefore inherits its parent stacking context — if the field sits inside a container with overflow hidden, give the menu room or a z-index.

The SelectRoot component registers a mousedown listener on the document inside a useEffect. On every click it checks whether the target is contained within the component ref; if it is not, it calls close() to set the open state false. Selecting an option also closes the menu, because onSelect runs your onChange and then close() in sequence.

Drivn binds the four parts with Object.assign, attaching Trigger, Menu, and Option as properties of the Select root. One import of Select gives you the whole compound, and the JSX reads as a tree. shadcn/ui spreads its Select across nine flat exports, which means longer import lines and remembering which name maps to which slot.

Yes. Treat it as a controlled input: hold the chosen value in state and pass value and onChange to the Select root. The component does not render a hidden native input, so on submit you read the value from your own state rather than from form data. This pairs cleanly with react-hook-form by registering the field through a Controller.