Select

A dropdown control that allows users to pick a value from a list of options.

Installation

Install the component Select in your project using the CLI.

Select.tsx

pnpm dlx behsseui@latest add Select

Install the component manually.

Create a ui folder at the root of the project, then a component folder inside it, and finally a Select.tsx file in that folder.

Copy and paste the following code into your project.

ui/components/Select.tsx

1"use client"
2
3import type { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from "react"
4import { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from "react"
5import { cn } from "@/lib/utils"
6import ChevronDown from "@/ui/icons/ChevronDown"
7import Check from "@/ui/icons/Check"
8
9// Context
10type SelectContextType = {
11 open: boolean
12 onOpenChange: (open: boolean) => void
13 value: string
14 onValueChange: (value: string) => void
15 disabled: boolean
16 triggerRef: React.RefObject<HTMLElement | null>
17 registerItem: (value: string, label: string) => void
18 itemLabels: React.RefObject<Map<string, string>>
19}
20
21const SelectContext = createContext<SelectContextType | undefined>(undefined)
22
23function useSelect() {
24 const context = useContext(SelectContext)
25 if (!context) {
26 throw new Error("Select components must be used within a Select")
27 }
28 return context
29}
30
31// Root
32type SelectProps = {
33 children: ReactNode
34 open?: boolean
35 defaultOpen?: boolean
36 onOpenChange?: (open: boolean) => void
37 value?: string
38 defaultValue?: string
39 onValueChange?: (value: string) => void
40 disabled?: boolean
41 name?: string
42}
43
44export function Select({
45 children,
46 open: controlledOpen,
47 defaultOpen = false,
48 onOpenChange,
49 value: controlledValue,
50 defaultValue = "",
51 onValueChange,
52 disabled = false,
53 name,
54}: SelectProps) {
55 const [internalOpen, setInternalOpen] = useState(defaultOpen)
56 const [internalValue, setInternalValue] = useState(defaultValue)
57 const triggerRef = useRef<HTMLElement | null>(null)
58 const itemLabels = useRef<Map<string, string>>(new Map())
59
60 const isOpenControlled = controlledOpen !== undefined
61 const open = isOpenControlled ? controlledOpen : internalOpen
62
63 const isValueControlled = controlledValue !== undefined
64 const value = isValueControlled ? controlledValue : internalValue
65
66 const handleOpenChange = useCallback((newOpen: boolean) => {
67 if (!isOpenControlled) {
68 setInternalOpen(newOpen)
69 }
70 onOpenChange?.(newOpen)
71 }, [isOpenControlled, onOpenChange])
72
73 const handleValueChange = useCallback((newValue: string) => {
74 if (!isValueControlled) {
75 setInternalValue(newValue)
76 }
77 onValueChange?.(newValue)
78 handleOpenChange(false)
79 }, [isValueControlled, onValueChange, handleOpenChange])
80
81 const registerItem = useCallback((itemValue: string, label: string) => {
82 itemLabels.current.set(itemValue, label)
83 }, [])
84
85 return (
86 <SelectContext.Provider value={{ open, onOpenChange: handleOpenChange, value, onValueChange: handleValueChange, disabled, triggerRef, registerItem, itemLabels }}>
87 <div className="relative inline-block">
88 {children}
89 {name && <input type="hidden" name={name} value={value} />}
90 </div>
91 </SelectContext.Provider>
92 )
93}
94
95// Trigger
96type SelectTriggerProps = {
97 children: ReactNode
98 className?: string
99} & ButtonHTMLAttributes<HTMLButtonElement>
100
101export function SelectTrigger({ children, className, ...props }: SelectTriggerProps) {
102 const { open, onOpenChange, disabled, triggerRef } = useSelect()
103
104 const handleClick = () => {
105 if (!disabled) {
106 onOpenChange(!open)
107 }
108 }
109
110 return (
111 <button
112 ref={triggerRef as React.RefObject<HTMLButtonElement>}
113 type="button"
114 role="combobox"
115 aria-expanded={open}
116 aria-haspopup="listbox"
117 disabled={disabled}
118 className={cn(
119 "flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors",
120 "placeholder:text-muted-foreground",
121 "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
122 "disabled:cursor-not-allowed disabled:opacity-50",
123 className
124 )}
125 onClick={handleClick}
126 {...props}
127 >
128 {children}
129 <ChevronDown className={cn("h-4 w-4 shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
130 </button>
131 )
132}
133
134// Value
135type SelectValueProps = {
136 placeholder?: string
137 className?: string
138}
139
140export function SelectValue({ placeholder, className }: SelectValueProps) {
141 const { value, itemLabels } = useSelect()
142
143 const displayLabel = value ? (itemLabels.current.get(value) || value) : null
144
145 return (
146 <span className={cn("truncate", !displayLabel && "text-muted-foreground", className)}>
147 {displayLabel || placeholder}
148 </span>
149 )
150}
151
152// Content
153type SelectContentProps = {
154 children: ReactNode
155 className?: string
156 side?: "bottom" | "top"
157 align?: "start" | "center" | "end"
158 sideOffset?: number
159} & HTMLAttributes<HTMLDivElement>
160
161export function SelectContent({
162 children,
163 className,
164 side,
165 align = "start",
166 sideOffset = 4,
167 ...props
168}: SelectContentProps) {
169 const { open, onOpenChange, triggerRef } = useSelect()
170 const contentRef = useRef<HTMLDivElement>(null)
171
172 // Compute side synchronously to avoid flash
173 const resolvedSide = useMemo<"bottom" | "top">(() => {
174 if (side) return side
175 if (!open) return "bottom"
176
177 const trigger = triggerRef.current
178 if (!trigger) return "bottom"
179
180 const rect = trigger.getBoundingClientRect()
181 const spaceBelow = window.innerHeight - rect.bottom
182 const spaceAbove = rect.top
183
184 if (spaceBelow < 200 && spaceAbove > spaceBelow) {
185 return "top"
186 }
187 return "bottom"
188 }, [open, side, triggerRef])
189
190 // Close on click outside
191 useEffect(() => {
192 if (!open) return
193
194 const handleClickOutside = (e: MouseEvent) => {
195 const content = contentRef.current
196 const trigger = triggerRef.current
197 if (!content) return
198 // Ignore clicks on the trigger — the trigger handles its own toggle
199 if (trigger?.contains(e.target as Node)) return
200 if (!content.contains(e.target as Node)) {
201 onOpenChange(false)
202 }
203 }
204
205 const timer = setTimeout(() => {
206 document.addEventListener("mousedown", handleClickOutside)
207 }, 0)
208
209 return () => {
210 clearTimeout(timer)
211 document.removeEventListener("mousedown", handleClickOutside)
212 }
213 }, [open, onOpenChange])
214
215 // Close on escape
216 useEffect(() => {
217 if (!open) return
218
219 const handleEscape = (e: KeyboardEvent) => {
220 if (e.key === "Escape") {
221 onOpenChange(false)
222 }
223 }
224
225 document.addEventListener("keydown", handleEscape)
226 return () => document.removeEventListener("keydown", handleEscape)
227 }, [open, onOpenChange])
228
229 if (!open) return null
230
231 const alignClasses = {
232 start: "left-0",
233 center: "left-1/2 -translate-x-1/2",
234 end: "right-0",
235 }
236
237 return (
238 <div
239 ref={contentRef}
240 role="listbox"
241 className={cn(
242 "absolute z-50 min-w-(--trigger-width,8rem) w-full rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
243 "animate-in fade-in-0 zoom-in-95",
244 resolvedSide === "bottom" && "slide-in-from-top-2",
245 resolvedSide === "top" && "slide-in-from-bottom-2",
246 resolvedSide === "bottom" ? "top-full" : "bottom-full",
247 alignClasses[align],
248 className
249 )}
250 style={{
251 ...(resolvedSide === "bottom" ? { marginTop: `${sideOffset}px` } : {}),
252 ...(resolvedSide === "top" ? { marginBottom: `${sideOffset}px` } : {}),
253 }}
254 {...props}
255 >
256 {children}
257 </div>
258 )
259}
260
261// Item
262type SelectItemProps = {
263 children: ReactNode
264 value: string
265 className?: string
266 disabled?: boolean
267} & HTMLAttributes<HTMLDivElement>
268
269export function SelectItem({
270 children,
271 value: itemValue,
272 className,
273 disabled = false,
274 ...props
275}: SelectItemProps) {
276 const { value, onValueChange, registerItem } = useSelect()
277
278 const isSelected = value === itemValue
279 const textContent = typeof children === "string" ? children : itemValue
280
281 // Register item label for display in trigger
282 useEffect(() => {
283 registerItem(itemValue, textContent)
284 }, [itemValue, textContent, registerItem])
285
286 const handleClick = () => {
287 if (disabled) return
288 onValueChange(itemValue)
289 }
290
291 return (
292 <div
293 role="option"
294 aria-selected={isSelected}
295 tabIndex={disabled ? -1 : 0}
296 data-disabled={disabled ? "" : undefined}
297 className={cn(
298 "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors whitespace-nowrap",
299 "hover:bg-accent hover:text-accent-foreground",
300 "focus:bg-accent focus:text-accent-foreground",
301 disabled && "pointer-events-none opacity-50",
302 className
303 )}
304 onClick={handleClick}
305 onKeyDown={(e) => {
306 if (e.key === "Enter" || e.key === " ") {
307 e.preventDefault()
308 handleClick()
309 }
310 }}
311 {...props}
312 >
313 <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
314 {isSelected && <Check className="h-4 w-4" />}
315 </span>
316 {children}
317 </div>
318 )
319}
320
321// Group
322type SelectGroupProps = {
323 children: ReactNode
324 className?: string
325} & HTMLAttributes<HTMLDivElement>
326
327export function SelectGroup({ children, className, ...props }: SelectGroupProps) {
328 return (
329 <div role="group" className={className} {...props}>
330 {children}
331 </div>
332 )
333}
334
335// Label
336type SelectLabelProps = {
337 children: ReactNode
338 className?: string
339} & HTMLAttributes<HTMLDivElement>
340
341export function SelectLabel({ children, className, ...props }: SelectLabelProps) {
342 return (
343 <div
344 className={cn("px-2 py-1.5 pl-8 text-sm font-semibold text-muted-foreground", className)}
345 {...props}
346 >
347 {children}
348 </div>
349 )
350}
351
352// Separator
353type SelectSeparatorProps = {
354 className?: string
355} & HTMLAttributes<HTMLDivElement>
356
357export function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
358 return (
359 <div
360 role="separator"
361 className={cn("-mx-1 my-1 h-px bg-border", className)}
362 {...props}
363 />
364 )
365}
366

Usages

Different variants and use cases for the Select component.

Default

A basic select with a list of options.

Default.tsx

<Select>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectItem value="banana">Banana</SelectItem>
    <SelectItem value="orange">Orange</SelectItem>
    <SelectItem value="grape">Grape</SelectItem>
    <SelectItem value="pineapple">Pineapple</SelectItem>
  </SelectContent>
</Select>

With Groups

Items organized into labeled groups with separators.

With Groups.tsx

<Select>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select food" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Fruits</SelectLabel>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
      <SelectItem value="orange">Orange</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectGroup>
      <SelectLabel>Vegetables</SelectLabel>
      <SelectItem value="carrot">Carrot</SelectItem>
      <SelectItem value="broccoli">Broccoli</SelectItem>
      <SelectItem value="spinach">Spinach</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Disabled

A disabled select that cannot be interacted with.

Disabled.tsx

<Select disabled>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectItem value="banana">Banana</SelectItem>
  </SelectContent>
</Select>

Disabled Items

A select with some items disabled.

Disabled Items.tsx

<Select>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectItem value="banana" disabled>Banana</SelectItem>
    <SelectItem value="orange">Orange</SelectItem>
    <SelectItem value="grape" disabled>Grape</SelectItem>
    <SelectItem value="pineapple">Pineapple</SelectItem>
  </SelectContent>
</Select>

With Label

A select with an external label.

With Label.tsx

<div className="grid w-full max-w-sm gap-1.5">
  <label htmlFor="framework" className="text-sm font-medium">Framework</label>
  <Select>
    <SelectTrigger>
      <SelectValue placeholder="Select a framework" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="next">Next.js</SelectItem>
      <SelectItem value="remix">Remix</SelectItem>
      <SelectItem value="astro">Astro</SelectItem>
      <SelectItem value="nuxt">Nuxt</SelectItem>
    </SelectContent>
  </Select>
</div>

Controlled

A fully controlled select with external state.

Controlled.tsx

const [value, setValue] = useState("")

<Select value={value} onValueChange={setValue}>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="apple">Apple</SelectItem>
    <SelectItem value="banana">Banana</SelectItem>
    <SelectItem value="orange">Orange</SelectItem>
  </SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Selected: {value || "(none)"}</p>