Overview
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 users choose from 5–15 predefined options
- When the options are mutually exclusive (pick one)
- When screen space is limited (dropdown collapses to a single line)
When NOT to use a Select:
- For fewer than 5 options — use Radio Buttons instead. They're faster (all options visible at once).
- For searchable/filterable lists — use a Combobox instead.
- For multiple selections — use Checkboxes or a multi-select Combobox.
- For binary choices — use a Switch or Checkbox.
Style your select's trigger with our Border Radius Generator and validate text contrast with the Contrast Checker.
Variants
Select Variants
| 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 Variants
| 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. |
Properties
Select Properties
| 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 Mappings
Design Token Mappings
| 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.
States
Select States
| 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 |
Accessibility
WCAG Requirements
| 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 |
ARIA Pattern (Custom Select)
<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>
Keyboard Interaction
| 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 |
Native vs. Custom: Accessibility Trade-offs
| 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.
Usage Guidelines
Do's
- ✅ Always pair with a visible label. Not a placeholder — placeholders disappear when a value is selected.
- ✅ Sort options logically. Alphabetical for names, chronological for dates, most-common-first for short lists.
- ✅ Use Radio Buttons for ≤4 options. They're faster — all options are visible without a click.
- ✅ Provide a "None" or "All" option when the selection is optional. Don't force a choice if one isn't required.
- ✅ Show the selected value clearly in the trigger after selection — don't revert to placeholder.
Don'ts
- ❌ Don't put more than ~15 options without search. After 15, users scroll endlessly. Add a search filter or use a Combobox.
- ❌ Don't use a Select for country/state pickers without search. 195 countries in a flat dropdown is a usability nightmare. Use a searchable Combobox.
- ❌ Don't auto-submit on selection change unless the user explicitly expects it (e.g., language switchers). Accidental selection changes shouldn't trigger actions.
- ❌ Don't mix select with navigation. If "selecting" an option navigates to a page, use a Navigation Bar or link list instead.
- ❌ Don't make the dropdown wider than the trigger by more than 50%. It feels disconnected.
Content Guidelines
- Option labels should be concise (1–4 words).
- Use sentence case for option labels.
- If options need context, add a description line (secondary text) rather than a longer label.
- Placeholder: "Select a color" not "Choose one" — be specific about what's being selected.
Code Snippets
<!-- 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>
);
}Design Systems
Cross-System Comparison
| 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 |
Notable Approaches
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.