Loading…
Loading…
Presents a list of options in a dropdown overlay for single selection.
The Select component (also called a dropdown or picker) presents a list of options in a floating overlay, allowing the user to choose one value. It's one of the most common form controls — and one of the hardest to build well.
The fundamental tension with Select is: native vs. custom. The native HTML <select> is fully accessible, works with assistive technology out of the box, and handles keyboard navigation perfectly. But it's nearly impossible to style consistently across browsers and can't support features like search, icons, option descriptions, or grouped headers.
Custom selects give you full visual control and richer features, but they require meticulous accessibility work — focus management, keyboard navigation, ARIA attributes, and screen reader announcements. Most custom select implementations in production have accessibility bugs.
When to use a Select:
When NOT to use a Select:
Style your select's trigger with our Border Radius Generator and validate text contrast with the Contrast Checker.
| Variant | Description | Best For |
|---|---|---|
| Native | Uses the browser's built-in <select> element. Zero JS. Fully accessible. Limited styling. | Simple forms, progressive enhancement, mobile forms (native pickers are excellent on iOS/Android) |
| Custom Single | Custom-built dropdown with full styling control. One selection. | Design-system selects where branding matters |
| Searchable | Includes a text input for filtering options. Technically a Combobox. | 10+ options where finding the right one quickly matters |
| Multi-select | Allows selecting multiple options, shown as tags/chips. | Filters, category assignment, permissions |
| Grouped | Options organized under labeled group headers (<optgroup>). | Country selectors (grouped by region), categorized settings |
| With descriptions | Each option has a secondary description line. | Role selectors ("Admin — Full access to all settings") |
| With icons | Each option shows an icon alongside the label. | Language selectors (flags), status selectors (colored dots) |
| Trigger Style | Description |
|---|---|
| Outlined | Border around the trigger. Most common. Clear hit target. |
| Filled | Light background fill, no visible border. Subtler. |
| Underlined | Bottom border only. Material Design style. |
| Ghost | No visible border until hover/focus. Use sparingly — low discoverability. |
| Property | Type | Default | Description |
|---|---|---|---|
value | string | — | Currently selected value (controlled) |
defaultValue | string | — | Initial value (uncontrolled) |
onChange | (value: string) => void | — | Callback when selection changes |
options | { value: string; label: string; disabled?: boolean }[] | [] | List of selectable options |
placeholder | string | "Select…" | Placeholder text when no value is selected |
disabled | boolean | false | Disables the entire select |
required | boolean | false | Marks the field as required in forms |
name | string | — | Form field name for native form submission |
error | boolean | string | — | Error state and/or message |
size | 'sm' | 'md' | 'lg' | 'md' | Trigger height and font size |
position | 'popper' | 'item-aligned' | 'popper' | Dropdown positioning strategy |
| Token Category | Token Example | Select Usage |
|---|---|---|
| Color – Trigger BG | --color-input-bg | Select trigger background |
| Color – Trigger Border | --color-border, --color-border-focus | Default and focused border color |
| Color – Option Hover | --color-surface-hover | Hovered option background |
| Color – Option Selected | --color-primary-100 | Currently selected option highlight |
| Color – Placeholder | --color-text-muted | Placeholder text color |
| Color – Error | --color-error-600 | Error border and message color |
| Spacing | --space-2 (8px), --space-3 (12px) | Option padding, trigger padding |
| Border Radius | --radius-md (8px) | Trigger and dropdown corners. Use Border Radius Generator. |
| Shadow | --shadow-lg | Dropdown panel elevation. Use Shadow Generator. |
| Typography | --font-size-sm | Option and trigger text |
| Transition | --duration-fast (150ms) | Dropdown open/close |
| Z-index | --z-dropdown (40) | Dropdown stacking above page content |
Explore token strategies in our Design Tokens Complete Guide and Theming with CSS Variables.
| State | Visual Change | Behavior |
|---|---|---|
| Default | Outlined trigger with placeholder or selected value | Ready for interaction |
| Hover | Border darkens or background subtly shifts | Cursor changes to pointer |
| Focus | Focus ring visible (2px, high contrast). Border color changes to primary. | Dropdown does NOT open on focus alone — wait for click or Enter/Space. |
| Open | Dropdown visible below (or above, if near viewport edge). Active option highlighted. | Focus moves to the option list. |
| Option Hover | Background highlight on the hovered option | Visual feedback |
| Option Selected | Checkmark icon or primary-tinted background on the selected option | Persists after dropdown closes |
| Disabled | Reduced opacity (0.5). No interaction. | aria-disabled="true" on the trigger |
| Error | Red border, error icon, error message below | Triggered by form validation |
| Loading | Spinner inside the trigger or in the dropdown | For async option loading |
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.3.1 Info and Relationships | A | Select must be associated with a visible <label> via for/id |
| SC 4.1.2 Name, Role, Value | A | Custom selects need role="listbox" on the option list and role="option" on each option |
| SC 2.1.1 Keyboard | A | All interactions must work via keyboard |
| SC 1.4.11 Non-text Contrast | AA | Trigger border must have 3:1 contrast against the background |
<label id="label-color">Favorite color</label>
<button
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="label-color"
aria-controls="listbox-color"
>
Select a color
</button>
<ul role="listbox" id="listbox-color" aria-labelledby="label-color" hidden>
<li role="option" aria-selected="true" id="opt-red">Red</li>
<li role="option" aria-selected="false" id="opt-blue">Blue</li>
<li role="option" aria-selected="false" id="opt-green">Green</li>
</ul>
| Key | Trigger Focused | Listbox Open |
|---|---|---|
| Enter / Space | Opens the listbox | Selects the focused option, closes listbox |
| Arrow Down | Opens listbox (or moves focus to next option if open) | Moves focus to next option |
| Arrow Up | Opens listbox (or moves focus to previous option) | Moves focus to previous option |
| Home | — | Moves to first option |
| End | — | Moves to last option |
| Escape | — | Closes listbox, returns focus to trigger |
| Type-ahead | Opens and jumps to matching option | Jumps to matching option |
| Aspect | Native <select> | Custom Select |
|---|---|---|
| Screen reader support | Perfect, zero effort | Requires meticulous ARIA |
| Keyboard navigation | Built-in | Must implement fully |
| Mobile experience | Native picker (excellent) | Custom dropdown (okay) |
| Styling control | Minimal | Full |
| Development effort | Minimal | Significant |
Recommendation: Use native <select> as your default. Only build custom when you genuinely need features native can't provide (search, icons, descriptions). For accessible custom selects, read our Accessible Forms Guide and check contrast with the Contrast Checker.
<!-- Native select (recommended baseline) -->
<div class="form-field">
<label for="role-select">Role</label>
<select id="role-select" name="role" required>
<option value="" disabled selected>Select a role</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<p class="helper-text">Choose the permission level for this user.</p>
</div>
<!-- Custom select trigger (for custom implementations) -->
<div class="form-field">
<label id="status-label">Status</label>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="status-label"
aria-controls="status-listbox"
class="select-trigger"
>
<span class="select-value">Select a status</span>
<svg aria-hidden="true" class="select-chevron" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"/>
</svg>
</button>
<ul role="listbox" id="status-listbox" aria-labelledby="status-label" hidden>
<li role="option" aria-selected="false">Active</li>
<li role="option" aria-selected="false">Inactive</li>
<li role="option" aria-selected="false">Pending</li>
</ul>
</div>import { useState, useRef, useEffect, useId } from "react";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps {
label: string;
options: Option[];
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
error?: string;
required?: boolean;
}
export default function Select({
label,
options,
value,
onChange,
placeholder = "Select an option",
error,
required,
}: SelectProps) {
const [open, setOpen] = useState(false);
const [focusIdx, setFocusIdx] = useState(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const id = useId();
const selected = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusIdx((i) => Math.min(i + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setFocusIdx((i) => Math.max(i - 1, 0));
break;
case "Enter":
case " ":
e.preventDefault();
if (focusIdx >= 0 && !options[focusIdx].disabled) {
onChange?.(options[focusIdx].value);
setOpen(false);
triggerRef.current?.focus();
}
break;
case "Escape":
setOpen(false);
triggerRef.current?.focus();
break;
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open, focusIdx, options, onChange]);
return (
<div className="form-field">
<label id={`${id}-label`}>{label}{required && " *"}</label>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-labelledby={`${id}-label`}
aria-controls={`${id}-listbox`}
className={`select-trigger ${error ? "select-error" : ""}`}
onClick={() => { setOpen(!open); setFocusIdx(options.findIndex((o) => o.value === value)); }}
>
{selected?.label ?? placeholder}
</button>
{open && (
<ul ref={listRef} role="listbox" id={`${id}-listbox`} aria-labelledby={`${id}-label`}>
{options.map((opt, i) => (
<li
key={opt.value}
role="option"
aria-selected={opt.value === value}
aria-disabled={opt.disabled || undefined}
className={i === focusIdx ? "option-focused" : ""}
onClick={() => { if (!opt.disabled) { onChange?.(opt.value); setOpen(false); } }}
>
{opt.label}
{opt.value === value && <span aria-hidden="true">✓</span>}
</li>
))}
</ul>
)}
{error && <p className="field-error" role="alert">{error}</p>}
</div>
);
}| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Component | Select (Outlined/Filled) | Select (Radix-based) | Select primitive | Select, AutoComplete |
| Custom rendering | Limited | Full via Radix | Full | optionRender prop |
| Search/filter | Exposed Dropdown Menu | Not built-in (use Combobox) | Not built-in | showSearch prop |
| Multi-select | Not native | Not native (use multi-combobox) | Not native | mode="multiple" |
| Option groups | Supported | Supported via SelectGroup | Select.Group | OptGroup |
| Positioning | Popper | Radix Popper | Configurable (popper or item-aligned) | Dropdown aligns to trigger |
| Native fallback | No | Hidden native <select> for form submission | Hidden native <select> | No |
| Accessibility | Material standards | Radix handles ARIA fully | Excellent — full ARIA listbox | Basic |
Radix Select ships with two positioning modes: popper (dropdown floats below) and item-aligned (the selected item aligns with the trigger, macOS-style). The item-aligned mode feels native on desktop but can cause issues on mobile — default to popper.
Radix also renders a hidden native <select> element alongside the custom one, ensuring form submissions work without JavaScript. This progressive enhancement detail is frequently overlooked in custom implementations.
Material 3 distinguishes between "Filled" and "Outlined" select triggers. The filled variant has a subtle background and bottom border; the outlined variant has a full border. Both support a "label" that animates from placeholder position to a floating label above the trigger — a signature Material interaction.
Ant Design bundles search (showSearch), multi-select (mode="multiple"), and tags (mode="tags") into a single Select component. This convenience comes at the cost of bundle size — you're shipping multi-select code even when you only need single-select.
Shadcn/ui wisely separates Select (simple single-select) from Combobox (searchable, autocomplete), following Radix's component separation. This keeps each component focused and lightweight.