Skip to content
Drivn
6 min read

How to Add a Select to a React App

Step-by-step guide to adding a select dropdown to any React app with the Drivn CLI — a copy-paste, click-outside-aware component built on React state.

Reaching for a select means one field in your React app is a single choice from a short, fixed list — a framework, a status, a timezone, a sort order — and the native <select> element, which cannot be styled to match a design, is not going to cut it. You could hand-roll a dropdown with a button, a positioned menu, and an outside-click listener, but getting the open state, the click-away close, and the affordances right is exactly the kind of fiddly work most teams would rather solve once.

Drivn solves it by copy-paste: the CLI writes a select.tsx file into your repository. The Select is a compound built with Object.assign — a SelectRoot joined to Trigger, Menu, and Option — that you call through dot notation. The root holds open in React.useState, shares the selected value through a React.createContext, and closes on an outside click via a mousedown listener, so select.tsx carries a 'use client' directive. It imports nothing past React, the ChevronDown icon from lucide-react, and the cn utility.

This guide adds the Select to any React app — install the CLI, render the control inside a client component, lift the chosen value into controlled state, and restyle the trigger and menu from the one styles object you own. It works the same in Vite + React or Next.js. For the App Router walkthrough see the Next.js Select guide.

Prerequisites

Before installing Select, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired; for a custom setup the installation page lists the minimal config. The Select is a small install — its only imports past React are the ChevronDown icon from lucide-react and the local cn utility for class merging, so lucide-react is the one package to have present. The one thing to keep in mind before you render it is that the Select is a controlled component: it keeps no internal copy of the value, so you hold the selection in your own state and feed it back through the value prop.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Select source file. The command prompts once for your install directory (defaulting to src/components/ui/) and writes a single select.tsx — no other files, because the component has no peer dependency beyond React, lucide-react, and the cn utility. No global config file is created; the result is TypeScript source you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. Because the file is yours after install, future Drivn releases will not overwrite your edits.

1# add the Select source file
2npx drivn add select

Step 2 — Render the select in a client component

Import Select and mount it inside a client component — because the root owns its open state and registers a mousedown listener, select.tsx ships with a 'use client' directive, so the file that renders it must be a client component too. One import covers the whole control: SelectRoot, Trigger, Menu, and Option are joined with Object.assign, so you write Select.Trigger, Select.Menu, and Select.Option off a single Select. Give the root a value and an onChange, put the current label inside Select.Trigger, and map your options into Select.Option elements inside Select.Menu. Each Select.Option carries a value string; clicking one fires the root's onChange with that value and closes the menu. Drop the control into a settings panel, a filter bar, or a form beside the other Drivn primitives.

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

Step 3 — Control the value and read the selection

The Select is controlled: the root destructures value and onChange and forwards them into context without keeping an internal copy, so the selection reflects only what your component holds. Hold the value in useState, pass it as the value prop, and update it from onChange, which fires with the chosen option's value string. Because the value lives in your state, you can use it anywhere else in the render — gate a submit button, filter a list, or post the choice to a route handler — not just inside the dropdown. The Trigger shows whatever you put in its children, usually the label resolved with a find on your options array, and falls back to the placeholder (default Select...) in text-muted-foreground when nothing is picked. To pre-select an option, initialise the state with that option's value instead of an empty string and the trigger renders its label on first paint.

1const [value, setValue] = useState('react') // pre-selected
2
3<Select value={value} onChange={setValue}>
4 <Select.Trigger placeholder="Pick a framework">
5 {frameworks.find((f) => f.value === value)?.label}
6 </Select.Trigger>
7 {/* ...menu */}
8</Select>

Step 4 — Restyle the trigger, menu, and options

Every class the component renders lives in one styles object at the top of the file you own. The trigger is a full-width h-10 button with a border-input, rounded-[10px] shell and a focus-visible:ring-[3px] focus ring; the menu is an absolutely positioned bg-card panel with a max-h-[200px] overflow-y-auto list and a soft shadow-lg; each option is a rounded-lg row that highlights with hover:bg-accent, and the active choice picks up selectedtext-primary font-medium. Recolor the active option, widen the menu's max height, or swap the ring token, all in one place. Because the defaults read theme tokens like --input, --card, --border, and --primary, adjusting them in theming re-themes every select for dark and light mode at once. A one-off className on the root, trigger, menu, or option merges after the base styles through cn(). For copy-paste layouts and stateful patterns see the Select examples.

1// verbatim from select.tsx
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 menu: cn(
13 'absolute top-full left-0 right-0 mt-1 z-50',
14 'bg-card border border-border rounded-[10px] p-1',
15 'shadow-lg max-h-[200px] overflow-y-auto',
16 'transition-[opacity,scale] duration-150 ease-out'
17 ),
18 option: cn(
19 'flex items-center w-full px-3 py-2 text-sm rounded-lg',
20 'hover:bg-accent transition-colors cursor-pointer'
21 ),
22 selected: 'text-primary font-medium',
23}
Get started

Install Drivn in one command

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

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. The root holds its open state in React.useState, keeps a React.useRef on its wrapper, and registers a mousedown listener to close on an outside click — all client-only APIs — so select.tsx ships with a 'use client' directive. In any React app that renders on the client it just works; in the Next.js App Router, mount it inside a client component and the surrounding route can stay a Server Component.

It is controlled. The root forwards the value and onChange you pass into context without keeping an internal copy, so the selection reflects only what your component holds. Hold the value in your own useState, pass it as value, and update it from onChange, which fires with the chosen value string and then closes the menu. Without a value and onChange the trigger label and highlighted option will not update.

Initialise your state with the value of the option you want pre-selected instead of an empty string. The trigger renders the label of whatever value is currently set — resolved with a find on your options array — so passing a real value on first render makes that option appear selected immediately, with no placeholder shown. The matching option also picks up the selected highlight inside the open menu.

No. The Select does not render a hidden native input, so it adds nothing to a form's FormData on submit — read the chosen value from your component state instead. With react-hook-form, register the field through a Controller and connect field.value and field.onChange to the Select root. For a searchable list of options rather than a fixed set, reach for the Combobox instead.

Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add select. The component has no dependency on Next.js or any router — it is a self-contained compound built on useState, useRef, context, and the ChevronDown icon, with no imports past React, lucide-react, and the cn utility. In a Vite app everything already runs on the client, so the 'use client' directive is simply ignored.