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 SelectInstall 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"23import 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"89// Context10type SelectContextType = {11 open: boolean12 onOpenChange: (open: boolean) => void13 value: string14 onValueChange: (value: string) => void15 disabled: boolean16 triggerRef: React.RefObject<HTMLElement | null>17 registerItem: (value: string, label: string) => void18 itemLabels: React.RefObject<Map<string, string>>19}2021const SelectContext = createContext<SelectContextType | undefined>(undefined)2223function useSelect() {24 const context = useContext(SelectContext)25 if (!context) {26 throw new Error("Select components must be used within a Select")27 }28 return context29}3031// Root32type SelectProps = {33 children: ReactNode34 open?: boolean35 defaultOpen?: boolean36 onOpenChange?: (open: boolean) => void37 value?: string38 defaultValue?: string39 onValueChange?: (value: string) => void40 disabled?: boolean41 name?: string42}4344export 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())5960 const isOpenControlled = controlledOpen !== undefined61 const open = isOpenControlled ? controlledOpen : internalOpen6263 const isValueControlled = controlledValue !== undefined64 const value = isValueControlled ? controlledValue : internalValue6566 const handleOpenChange = useCallback((newOpen: boolean) => {67 if (!isOpenControlled) {68 setInternalOpen(newOpen)69 }70 onOpenChange?.(newOpen)71 }, [isOpenControlled, onOpenChange])7273 const handleValueChange = useCallback((newValue: string) => {74 if (!isValueControlled) {75 setInternalValue(newValue)76 }77 onValueChange?.(newValue)78 handleOpenChange(false)79 }, [isValueControlled, onValueChange, handleOpenChange])8081 const registerItem = useCallback((itemValue: string, label: string) => {82 itemLabels.current.set(itemValue, label)83 }, [])8485 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}9495// Trigger96type SelectTriggerProps = {97 children: ReactNode98 className?: string99} & ButtonHTMLAttributes<HTMLButtonElement>100101export function SelectTrigger({ children, className, ...props }: SelectTriggerProps) {102 const { open, onOpenChange, disabled, triggerRef } = useSelect()103104 const handleClick = () => {105 if (!disabled) {106 onOpenChange(!open)107 }108 }109110 return (111 <button112 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 className124 )}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}133134// Value135type SelectValueProps = {136 placeholder?: string137 className?: string138}139140export function SelectValue({ placeholder, className }: SelectValueProps) {141 const { value, itemLabels } = useSelect()142143 const displayLabel = value ? (itemLabels.current.get(value) || value) : null144145 return (146 <span className={cn("truncate", !displayLabel && "text-muted-foreground", className)}>147 {displayLabel || placeholder}148 </span>149 )150}151152// Content153type SelectContentProps = {154 children: ReactNode155 className?: string156 side?: "bottom" | "top"157 align?: "start" | "center" | "end"158 sideOffset?: number159} & HTMLAttributes<HTMLDivElement>160161export function SelectContent({162 children,163 className,164 side,165 align = "start",166 sideOffset = 4,167 ...props168}: SelectContentProps) {169 const { open, onOpenChange, triggerRef } = useSelect()170 const contentRef = useRef<HTMLDivElement>(null)171172 // Compute side synchronously to avoid flash173 const resolvedSide = useMemo<"bottom" | "top">(() => {174 if (side) return side175 if (!open) return "bottom"176177 const trigger = triggerRef.current178 if (!trigger) return "bottom"179180 const rect = trigger.getBoundingClientRect()181 const spaceBelow = window.innerHeight - rect.bottom182 const spaceAbove = rect.top183184 if (spaceBelow < 200 && spaceAbove > spaceBelow) {185 return "top"186 }187 return "bottom"188 }, [open, side, triggerRef])189190 // Close on click outside191 useEffect(() => {192 if (!open) return193194 const handleClickOutside = (e: MouseEvent) => {195 const content = contentRef.current196 const trigger = triggerRef.current197 if (!content) return198 // Ignore clicks on the trigger — the trigger handles its own toggle199 if (trigger?.contains(e.target as Node)) return200 if (!content.contains(e.target as Node)) {201 onOpenChange(false)202 }203 }204205 const timer = setTimeout(() => {206 document.addEventListener("mousedown", handleClickOutside)207 }, 0)208209 return () => {210 clearTimeout(timer)211 document.removeEventListener("mousedown", handleClickOutside)212 }213 }, [open, onOpenChange])214215 // Close on escape216 useEffect(() => {217 if (!open) return218219 const handleEscape = (e: KeyboardEvent) => {220 if (e.key === "Escape") {221 onOpenChange(false)222 }223 }224225 document.addEventListener("keydown", handleEscape)226 return () => document.removeEventListener("keydown", handleEscape)227 }, [open, onOpenChange])228229 if (!open) return null230231 const alignClasses = {232 start: "left-0",233 center: "left-1/2 -translate-x-1/2",234 end: "right-0",235 }236237 return (238 <div239 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 className249 )}250 style={{251 ...(resolvedSide === "bottom" ? { marginTop: `${sideOffset}px` } : {}),252 ...(resolvedSide === "top" ? { marginBottom: `${sideOffset}px` } : {}),253 }}254 {...props}255 >256 {children}257 </div>258 )259}260261// Item262type SelectItemProps = {263 children: ReactNode264 value: string265 className?: string266 disabled?: boolean267} & HTMLAttributes<HTMLDivElement>268269export function SelectItem({270 children,271 value: itemValue,272 className,273 disabled = false,274 ...props275}: SelectItemProps) {276 const { value, onValueChange, registerItem } = useSelect()277278 const isSelected = value === itemValue279 const textContent = typeof children === "string" ? children : itemValue280281 // Register item label for display in trigger282 useEffect(() => {283 registerItem(itemValue, textContent)284 }, [itemValue, textContent, registerItem])285286 const handleClick = () => {287 if (disabled) return288 onValueChange(itemValue)289 }290291 return (292 <div293 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 className303 )}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}320321// Group322type SelectGroupProps = {323 children: ReactNode324 className?: string325} & HTMLAttributes<HTMLDivElement>326327export function SelectGroup({ children, className, ...props }: SelectGroupProps) {328 return (329 <div role="group" className={className} {...props}>330 {children}331 </div>332 )333}334335// Label336type SelectLabelProps = {337 children: ReactNode338 className?: string339} & HTMLAttributes<HTMLDivElement>340341export function SelectLabel({ children, className, ...props }: SelectLabelProps) {342 return (343 <div344 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}351352// Separator353type SelectSeparatorProps = {354 className?: string355} & HTMLAttributes<HTMLDivElement>356357export function SelectSeparator({ className, ...props }: SelectSeparatorProps) {358 return (359 <div360 role="separator"361 className={cn("-mx-1 my-1 h-px bg-border", className)}362 {...props}363 />364 )365}366Usages
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>