Select
Displays a list of options for the user to pick from.
The Select component provides a dropdown menu for selecting a single value from a list of options. It supports groups, labels, separators, and keyboard navigation. Built from scratch using React and native HTML elements. No dependencies on any UI library.
Code
TypeScript: Copy this code into components/ui/select.tsx:
tsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
interface SelectContextValue {
open: boolean
setOpen: (open: boolean) => void
value?: string
onValueChange?: (value: string) => void
triggerRef: React.RefObject<HTMLElement>
}
const SelectContext = React.createContext<SelectContextValue | undefined>(undefined)
const useSelect = () => {
const context = React.useContext(SelectContext)
if (!context) {
throw new Error("Select components must be used within Select")
}
return context
}
interface SelectProps {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
children: React.ReactNode
}
const Select = ({ value: controlledValue, defaultValue, onValueChange, children }: SelectProps) => {
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | undefined>(defaultValue)
const [open, setOpen] = React.useState(false)
const triggerRef = React.useRef<HTMLElement>(null)
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
const handleValueChange = React.useCallback((newValue: string) => {
if (!isControlled) {
setUncontrolledValue(newValue)
}
onValueChange?.(newValue)
setOpen(false)
}, [isControlled, onValueChange])
React.useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
const portalContent = document.querySelector('[data-select-content]')
if (
portalContent &&
!portalContent.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
setOpen(false)
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open])
return (
<SelectContext.Provider value={{ open, setOpen, value, onValueChange: handleValueChange, triggerRef }}>
{children}
</SelectContext.Provider>
)
}
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
placeholder?: string
}
const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ className, placeholder, children, ...props }, ref) => {
const { open, setOpen, value, triggerRef: contextTriggerRef } = useSelect()
const localTriggerRef = React.useRef<HTMLButtonElement>(null)
React.useImperativeHandle(ref, () => localTriggerRef.current as HTMLButtonElement)
React.useEffect(() => {
if (localTriggerRef.current) {
(contextTriggerRef as React.MutableRefObject<HTMLElement | null>).current = localTriggerRef.current
}
}, [contextTriggerRef])
const handleClick = () => {
setOpen(!open)
}
return (
<button
ref={localTriggerRef}
onClick={handleClick}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-background px-3 py-2 text-sm font-bold ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
{...props}
>
<span className={cn(!value && "text-muted-foreground")}>
{value ? children : placeholder || "Select an option"}
</span>
<span className={cn("transition-transform", open && "rotate-180")}>▼</span>
</button>
)
}
)
SelectTrigger.displayName = "SelectTrigger"
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
({ className, children, ...props }, ref) => {
const { open, triggerRef } = useSelect()
const contentRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => contentRef.current as HTMLDivElement)
React.useEffect(() => {
if (!open || !contentRef.current || !triggerRef.current) return
const content = contentRef.current
const trigger = triggerRef.current
const triggerRect = trigger.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let top = triggerRect.bottom + 4
let left = triggerRect.left
if (left + contentRect.width > viewportWidth) {
left = viewportWidth - contentRect.width - 8
}
if (left < 8) left = 8
if (top + contentRect.height > viewportHeight) {
top = triggerRect.top - contentRect.height - 4
}
if (top < 8) top = 8
content.style.position = "fixed"
content.style.top = `${top}px`
content.style.left = `${left}px`
}, [open, triggerRef])
if (!open) return null
const content = (
<div
ref={contentRef}
data-select-content
className={cn(
"fixed z-50 min-w-[8rem] overflow-hidden rounded-md border-2 border-foreground bg-background neobrutalism-shadow",
className
)}
onMouseDown={(e) => e.stopPropagation()}
{...props}
>
{children}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
SelectContent.displayName = "SelectContent"
interface SelectItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string
}
const SelectItem = React.forwardRef<HTMLButtonElement, SelectItemProps>(
({ className, value, children, ...props }, ref) => {
const { value: selectedValue, onValueChange } = useSelect()
const isSelected = selectedValue === value
const handleClick = () => {
onValueChange?.(value)
}
return (
<button
ref={ref}
onClick={handleClick}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-bold outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground",
className
)}
{...props}
>
{children}
</button>
)
}
)
SelectItem.displayName = "SelectItem"
interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectLabel = React.forwardRef<HTMLDivElement, SelectLabelProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-2 py-1.5 text-sm font-bold", className)}
{...props}
/>
)
)
SelectLabel.displayName = "SelectLabel"
interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectGroup = React.forwardRef<HTMLDivElement, SelectGroupProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("space-y-1", className)}
{...props}
/>
)
)
SelectGroup.displayName = "SelectGroup"
export {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectLabel,
SelectGroup,
}JavaScript: Copy this code into components/ui/select.jsx:
jsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
const SelectContext = React.createContext(undefined)
const useSelect = () => {
const context = React.useContext(SelectContext)
if (!context) {
throw new Error("Select components must be used within Select")
}
return context
}
const Select = ({ value: controlledValue, defaultValue, onValueChange, children }) => {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
const [open, setOpen] = React.useState(false)
const triggerRef = React.useRef(null)
const valueToLabelMap = React.useRef(new Map())
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
const registerItem = React.useCallback((itemValue, label) => {
valueToLabelMap.current.set(itemValue, label)
}, [])
const getLabel = React.useCallback((itemValue) => {
return valueToLabelMap.current.get(itemValue)
}, [])
const handleValueChange = React.useCallback((newValue) => {
if (!isControlled) {
setUncontrolledValue(newValue)
}
onValueChange?.(newValue)
setOpen(false)
}, [isControlled, onValueChange])
React.useEffect(() => {
if (!open) return
const handleClick = (e) => {
const target = e.target
const portalContent = document.querySelector('[data-select-content]')
if (
portalContent &&
!portalContent.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
setOpen(false)
}
}
const handleEscape = (e) => {
if (e.key === "Escape") {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open])
return (
<SelectContext.Provider value={{ open, setOpen, value, onValueChange: handleValueChange, triggerRef, registerItem, getLabel }}>
{children}
</SelectContext.Provider>
)
}
const SelectTrigger = React.forwardRef(
({ className, placeholder, children, ...props }, ref) => {
const { open, setOpen, triggerRef: contextTriggerRef } = useSelect()
const localTriggerRef = React.useRef(null)
React.useImperativeHandle(ref, () => localTriggerRef.current)
React.useEffect(() => {
if (localTriggerRef.current) {
contextTriggerRef.current = localTriggerRef.current
}
}, [contextTriggerRef])
const handleClick = () => {
setOpen(!open)
}
return (
<button
ref={localTriggerRef}
onClick={handleClick}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-background px-3 py-2 text-sm font-bold ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 neobrutalism-shadow",
className
)}
{...props}
>
{children || <span className="text-muted-foreground">{placeholder || "Select an option"}</span>}
<span className={cn("transition-transform", open && "rotate-180")}>▼</span>
</button>
)
}
)
SelectTrigger.displayName = "SelectTrigger"
const SelectValue = ({ placeholder }) => {
const { value, getLabel } = useSelect()
if (!value) {
return <>{placeholder || "Select an option"}</>
}
const label = getLabel(value)
return <>{label || value}</>
}
SelectValue.displayName = "SelectValue"
const SelectContent = React.forwardRef(
({ className, children, ...props }, ref) => {
const { open, triggerRef } = useSelect()
const contentRef = React.useRef(null)
React.useImperativeHandle(ref, () => contentRef.current)
React.useEffect(() => {
if (!open || !contentRef.current || !triggerRef.current) return
const content = contentRef.current
const trigger = triggerRef.current
const triggerRect = trigger.getBoundingClientRect()
const contentRect = content.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let top = triggerRect.bottom + 4
let left = triggerRect.left
if (left + contentRect.width > viewportWidth) {
left = viewportWidth - contentRect.width - 8
}
if (left < 8) left = 8
if (top + contentRect.height > viewportHeight) {
top = triggerRect.top - contentRect.height - 4
}
if (top < 8) top = 8
content.style.position = "fixed"
content.style.top = `${top}px`
content.style.left = `${left}px`
}, [open, triggerRef])
if (!open) return null
const content = (
<div
ref={contentRef}
data-select-content
className={cn(
"fixed z-50 min-w-[8rem] overflow-hidden rounded-md border-2 border-foreground bg-background neobrutalism-shadow",
className
)}
onMouseDown={(e) => e.stopPropagation()}
{...props}
>
{children}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
SelectContent.displayName = "SelectContent"
const SelectItem = React.forwardRef(
({ className, value, children, ...props }, ref) => {
const { value: selectedValue, onValueChange, registerItem } = useSelect()
const isSelected = selectedValue === value
const labelRef = React.useRef()
React.useEffect(() => {
const label = typeof children === "string" ? children : children?.props?.children || value
labelRef.current = label
registerItem(value, label)
}, [value, children, registerItem])
const handleClick = () => {
onValueChange?.(value)
}
return (
<button
ref={ref}
data-select-item-value={value}
onClick={handleClick}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-bold outline-none transition-colors",
"focus:bg-accent focus:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground",
className
)}
{...props}
>
{children}
</button>
)
}
)
SelectItem.displayName = "SelectItem"
const SelectLabel = React.forwardRef(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-2 py-1.5 text-sm font-bold", className)}
{...props}
/>
)
)
SelectLabel.displayName = "SelectLabel"
const SelectGroup = React.forwardRef(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("space-y-1", className)}
{...props}
/>
)
)
SelectGroup.displayName = "SelectGroup"
const SelectSeparator = React.forwardRef(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("my-1 h-px bg-foreground", className)}
{...props}
/>
)
)
SelectSeparator.displayName = "SelectSeparator"
export {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectLabel,
SelectGroup,
SelectSeparator,
}Usage
TypeScript:
tsx
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select"
import * as React from "react"
function MyComponent() {
const [value, setValue] = React.useState<string>()
return (
<Select value={value} onValueChange={setValue}>
<SelectTrigger placeholder="Select a fruit">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
</Select>
)
}JavaScript:
jsx
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select"
import { useState } from "react"
function MyComponent() {
const [value, setValue] = useState()
return (
<Select value={value} onValueChange={setValue}>
<SelectTrigger placeholder="Select a fruit">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
</Select>
)
}Make sure you also have the lib/utils.ts file with the cn helper function.