Next.js Select with the App Router
Add a select dropdown to a Next.js App Router app. Drivn ships a copy-paste, click-outside-aware component built on React state with zero runtime deps.
A select is the compact way to let a user pick one value from many — a framework in a settings form, a timezone, a status filter — without the vertical cost of a radio group. In a Next.js App Router project the thing to know up front is that Drivn's Select is interactive: it owns its open state, closes on an outside click, and rotates its chevron as it opens, so select.tsx carries a 'use client' directive and mounts inside a client boundary rather than a bare Server Component.
After install the component sits in src/components/ui/select.tsx as a compound built with Object.assign — a SelectRoot joined to Trigger, Menu, and Option. The root holds open in React.useState, tracks the outside click through a React.useRef and a mousedown listener, and shares the selected value plus an onSelect callback down through a React.createContext. Each sub-component reads that context with a useSelect hook, so one import wires the whole control.
This guide installs Drivn in a Next.js 16 project, renders the select inside a small client component, lifts the value into controlled state, and restyles the menu and options from the one styles object you own. Every snippet comes from the component's real API. For the full reference see the Select docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Select.
Install in a Next.js 16 project
Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add select. The Select imports React, the ChevronDown icon from lucide-react, and the local cn utility, so the CLI writes a single select.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a form. If the same screen also needs a searchable list, pair it with the Drivn Combobox from the same install.
1 # from the root of your Next.js 16 project 2 npx drivn add select
Render the select in a client component
Because the Select owns its open state and registers a mousedown listener to close on an outside click, select.tsx ships with a 'use client' directive — so mount it inside a client component and keep the surrounding App Router route a Server Component. 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. The root does not store the value itself — it forwards whatever value you pass down through context — so the parent stays the source of truth for what is selected. Drop this control into a settings panel, a filter bar, or a form beside the other Drivn primitives.
1 'use client' 2 import * as React from 'react' 3 import { Select } from '@/components/ui/select' 4 5 const frameworks = [ 6 { label: 'React', value: 'react' }, 7 { label: 'Vue', value: 'vue' }, 8 { label: 'Svelte', value: 'svelte' }, 9 ] 10 11 export function FrameworkSelect() { 12 const [value, setValue] = React.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 }
Control the value and show the selected label
The Select is a controlled component: the root destructures value and onChange and passes them straight into context without keeping an internal copy, so the selection only reflects what the parent holds. Hold the value in React.useState, pass it as the value prop, and update it from onChange, which fires with the chosen option's value string once and then closes the menu. The Trigger shows whatever you put in its children — usually the label of the current option, resolved with a find on your options array — and falls back to the placeholder prop (default Select...) when children is empty, rendering that fallback in text-muted-foreground so it reads as a hint rather than a value. Because onChange hands you the raw string, you can gate a submit button, branch the UI, or post the value to a route handler without unwrapping an event. The matching Option also picks up styles.selected — text-primary font-medium — so the active choice stays highlighted inside the open menu.
1 <Select.Trigger placeholder="Pick a framework"> 2 {frameworks.find((f) => f.value === value)?.label} 3 </Select.Trigger>
How the open state and outside-click close work
The interactivity is small and self-contained. SelectRoot holds open in React.useState and toggles it from the Trigger's onClick; the ChevronDown icon reads that flag and adds rotate-180 when the menu is open, so the caret flips as an affordance. Closing on an outside click is a single effect: the root keeps a React.useRef on its wrapper <div> and attaches a mousedown listener to document that calls close() whenever the click target is not inside the ref. The Menu itself never unmounts — it stays in the DOM and toggles opacity-100 scale-100 against opacity-0 scale-95 pointer-events-none, so opening and closing animate through the transition-[opacity,scale] on styles.menu instead of popping in. That is the entire client footprint: one boolean, one ref, one listener, and a context to share the value.
1 // verbatim from select.tsx 2 React.useEffect(() => { 3 const onClick = (e: MouseEvent) => { 4 if (!ref.current?.contains(e.target as Node)) close() 5 } 6 document.addEventListener('mousedown', onClick) 7 return () => document.removeEventListener('mousedown', onClick) 8 }, [close])
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
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 a Next.js App Router page the boundary stays local: mount the Select inside a small client component and the surrounding route remains a Server Component. Only the interactive control ships to the client, not the whole page.
It is controlled. The root destructures value and onChange and forwards them into context without keeping an internal copy, so the selection reflects only what the parent holds. Hold the value in your own React.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 the highlighted option will not update.
The root keeps a React.useRef on its wrapping <div> and attaches a mousedown listener to document in a React.useEffect. On every mousedown it checks whether the event target is inside the ref; if it is not, it calls close() to set open to false. The listener is removed on unmount through the effect's cleanup, so there is no leak. This is why the whole control must sit inside one wrapper element.
The Menu never unmounts — it stays in the DOM and toggles Tailwind utilities based on the open state. When open it applies opacity-100 scale-100; when closed it applies opacity-0 scale-95 pointer-events-none. Because styles.menu carries a transition-[opacity,scale] duration-150 ease-out, the change between those states animates the panel in and out. The pointer-events-none on the closed state keeps the hidden menu from catching clicks.
Yes. The component has no dependency on Next.js or any router — it is a self-contained compound built on React.useState, React.useRef, context, and the ChevronDown icon, with no imports past React, lucide-react, and the cn utility. In a Vite + React app everything already runs on the client, so the 'use client' directive is simply ignored; run npx drivn add select and drive its value from your own useState exactly as you would in Next.js.

