Select
A dropdown selector for choosing from a list of options. Supports native browser select behavior for accessibility and mobile compatibility, with custom variants for advanced use cases.
Search across all documentation pages
A dropdown selector for choosing from a list of options. Supports native browser select behavior for accessibility and mobile compatibility, with custom variants for advanced use cases.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ label, value, onChange, options }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}A minimal labeled select. The onChange callback returns the string value directly so the parent does not need to unwrap e.target.value. The native <select> element provides built-in keyboard navigation and mobile-friendly pickers for free.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
required?: boolean;
}
export function Select({ label, value, onChange, options, required }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
className="mt-1 block w-full appearance-none rounded-lg border border-gray-300 bg-white bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236b7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%2001.02-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Uses appearance-none to remove the browser default arrow and replaces it with an inline SVG chevron via background-image. This gives full control over the dropdown indicator styling while retaining native select behavior.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function Select({
label,
value,
onChange,
options,
placeholder = "Select an option",
}: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 ${
value === "" ? "text-gray-400" : "text-gray-900"
}`}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}A disabled first option acts as the placeholder. The text color is gray when nothing is selected and switches to the normal color once an option is chosen. Using disabled on the placeholder option prevents it from being re-selected after the user picks a value.
"use client";
interface OptionGroup {
label: string;
options: { value: string; label: string }[];
}
interface GroupedSelectProps {
label: string;
value: string;
onChange: (value: string) => void;
groups: OptionGroup[];
}
export function GroupedSelect({ label, value, onChange, groups }: GroupedSelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="" disabled>
Select an option
</option>
{groups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
</label>
);
}Uses the native <optgroup> element to visually group related options under bold headings. This is ideal for long option lists like countries grouped by continent or categories grouped by department.
"use client";
import { useState, useRef, useEffect } from "react";
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
label: string;
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function toggleOption(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={selected.length === 0 ? "text-gray-400" : "text-gray-900"}>
{selected.length === 0
? "Select options"
: `${selected.length} selected`}
</span>
<svg
className={`h-4 w-4 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<ul className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{options.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => toggleOption(opt.value)}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-gray-50"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
selected.includes(opt.value)
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300"
}`}
>
{selected.includes(opt.value) && (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
{opt.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}A custom multi-select dropdown built with buttons and a popover list. Clicking outside closes the dropdown via a mousedown listener. Each option renders a visual checkbox indicator. The trigger button shows the count of selected items.
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
interface Option {
value: string;
label: string;
}
interface SearchableSelectProps {
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchableSelect({
label,
options,
value,
onChange,
placeholder = "Search...",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())),
[options, query]
);
const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleSelect(optionValue: string) {
onChange(optionValue);
setOpen(false);
setQuery("");
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={value ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || "Select an option"}
</span>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<ul className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-500">No results found</li>
) : (
filtered.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => handleSelect(opt.value)}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 ${
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "text-gray-900"
}`}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}A dropdown with a search input that filters options as the user types. The selected option is highlighted in the list with a blue background. The useMemo hook prevents re-filtering on every render. Focus is moved to the search input on open using a setTimeout to wait for the DOM to update.
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
error?: string;
disabled?: boolean;
}
export function Select({ label, value, onChange, options, error, disabled }: SelectProps) {
return (
<div className="block">
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
>
<option value="" disabled>
Select an option
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}Combines controlled value, error display, and disabled state. The aria-invalid attribute informs screen readers of the validation error. Disabled styling prevents interaction while keeping the selected value visible.
"use client";
import { forwardRef, useId, useState, useRef, useEffect, useCallback, useMemo } from "react";
type SelectSize = "sm" | "md" | "lg";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface OptionGroup {
label: string;
options: Option[];
}
interface SelectProps {
label?: string;
helperText?: string;
error?: string;
size?: SelectSize;
options?: Option[];
groups?: OptionGroup[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
id?: string;
}
const sizeClasses: Record<SelectSize, { trigger: string; text: string; option: string }> = {
sm: { trigger: "h-8 px-2.5 text-xs", text: "text-xs", option: "px-2.5 py-1.5 text-xs" },
md: { trigger: "h-10 px-3 text-sm", text: "text-sm", option: "px-3 py-2 text-sm" },
lg: { trigger: "h-12 px-4 text-base", text: "text-base", option: "px-4 py-2.5 text-base" },
};
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
{
label,
helperText,
error,
size = "md",
options = [],
groups,
value,
onChange,
placeholder = "Select an option",
searchable = false,
disabled = false,
fullWidth = true,
id: externalId,
},
ref
) {
const generatedId = useId();
const selectId = externalId ?? generatedId;
const errorId = `${selectId}-error`;
const helperId = `${selectId}-helper`;
const listboxId = `${selectId}-listbox`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allOptions = useMemo(() => {
if (groups) {
return groups.flatMap((g) => g.options);
}
return options;
}, [options, groups]);
const filtered = useMemo(() => {
if (!searchable || !query) return allOptions;
return allOptions.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
}, [allOptions, query, searchable]);
const selectedLabel = allOptions.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
return;
}
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((i) =>
i < filtered.length - 1 ? i + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((i) =>
i > 0 ? i - 1 : filtered.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filtered[highlightedIndex]) {
const opt = filtered[highlightedIndex];
if (!opt.disabled) handleSelect(opt.value);
}
break;
case "Escape":
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
break;
}
},
[open, filtered, highlightedIndex, handleSelect]
);
const hasError = !!error;
const sizes = sizeClasses[size];
return (
<div
ref={containerRef}
className={fullWidth ? "w-full" : "inline-flex flex-col"}
onKeyDown={handleKeyDown}
>
{label && (
<label
htmlFor={selectId}
className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}
>
{label}
</label>
)}
<button
ref={ref}
id={selectId}
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-invalid={hasError}
aria-describedby={
[hasError ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((o) => !o);
if (searchable) setTimeout(() => inputRef.current?.focus(), 0);
}
}}
className={[
"flex items-center justify-between rounded-lg border shadow-sm transition-colors",
"focus:outline-none focus:ring-1",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
sizes.trigger,
fullWidth ? "w-full" : "",
hasError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500",
]
.filter(Boolean)
.join(" ")}
>
<span className={value ? "text-gray-900 truncate" : "text-gray-400 truncate"}>
{selectedLabel || placeholder}
</span>
<svg
className={`ml-2 h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="relative">
<div className="absolute z-20 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
{searchable && (
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
}}
placeholder="Search..."
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
<ul id={listboxId} role="listbox" className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className={`${sizes.option} text-gray-500`}>No results found</li>
) : (
filtered.map((opt, idx) => (
<li
key={opt.value}
role="option"
aria-selected={opt.value === value}
aria-disabled={opt.disabled}
>
<button
type="button"
onClick={() => !opt.disabled && handleSelect(opt.value)}
className={[
"w-full text-left",
sizes.option,
opt.disabled
? "cursor-not-allowed text-gray-400"
: "hover:bg-gray-50",
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "",
idx === highlightedIndex ? "bg-gray-100" : "",
]
.filter(Boolean)
.join(" ")}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
</div>
)}
<div className="mt-1">
{hasError && (
<p id={errorId} className={`text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</div>
</div>
);
});Key aspects:
<select> behavior.role="combobox", aria-expanded, aria-haspopup="listbox", and aria-controls to communicate the dropdown state to assistive technology.searchable is true, a text input appears at the top of the dropdown and filters options with useMemo for performance. Focus is moved to the input automatically on open.options or structured groups, flattened internally with useMemo so filtering and keyboard navigation work uniformly.aria-disabled and a grayed-out style, preventing selection while remaining visible in the list.mousedown listener closes the dropdown when clicking outside the container, cleaned up on unmount to prevent memory leaks.Native select styling limitations -- The <option> element cannot be styled with CSS in most browsers. If you need custom option rendering (icons, colors, descriptions), you must build a custom dropdown instead of using native <select>.
Placeholder option re-selectable -- A <option value="" disabled> placeholder cannot be re-selected after the user picks a value unless you add a separate "clear" mechanism. Consider whether your UX needs a reset button.
z-index stacking context -- Custom dropdown menus need a high enough z-index to appear above other positioned elements. If the dropdown appears behind a sibling, check for overflow: hidden on ancestor containers or create a portal.
Mobile experience with custom selects -- Native <select> provides the platform's native picker on mobile (spinner on Android, wheel on iOS). Custom dropdowns lose this benefit. Consider using a native select on mobile and a custom one on desktop.
Form data with custom selects -- Custom selects built with buttons do not submit values with native form submission. Add a <input type="hidden" name="..." value={value} /> inside the form to include the value in FormData.
onChange fires on same value -- Native <select> does not fire onChange when the user re-selects the already-selected option. If you need to detect re-selection, use an onClick handler on options instead.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥