Skip to content

Select

Presents a list of options in a dropdown overlay for single selection.

  • Dropdown
  • Picker
  • Listbox

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

VariantDescriptionBest For
NativeUses 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 SingleCustom-built dropdown with full styling control. One selection.Design-system selects where branding matters
SearchableIncludes a text input for filtering options. Technically a Combobox.10+ options where finding the right one quickly matters
Multi-selectAllows selecting multiple options, shown as tags/chips.Filters, category assignment, permissions
GroupedOptions organized under labeled group headers (<optgroup>).Country selectors (grouped by region), categorized settings
With descriptionsEach option has a secondary description line.Role selectors ("Admin — Full access to all settings")
With iconsEach option shows an icon alongside the label.Language selectors (flags), status selectors (colored dots)

Trigger Variants

Trigger StyleDescription
OutlinedBorder around the trigger. Most common. Clear hit target.
FilledLight background fill, no visible border. Subtler.
UnderlinedBottom border only. Material Design style.
GhostNo visible border until hover/focus. Use sparingly — low discoverability.

Properties

Select Properties

PropertyTypeDefaultDescription
valuestring—Currently selected value (controlled)
defaultValuestring—Initial value (uncontrolled)
onChange(value: string) => void—Callback when selection changes
options{ value: string; label: string; disabled?: boolean }[][]List of selectable options
placeholderstring"Select…"Placeholder text when no value is selected
disabledbooleanfalseDisables the entire select
requiredbooleanfalseMarks the field as required in forms
namestring—Form field name for native form submission
errorboolean | 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 CategoryToken ExampleSelect Usage
Color – Trigger BG--color-input-bgSelect trigger background
Color – Trigger Border--color-border, --color-border-focusDefault and focused border color
Color – Option Hover--color-surface-hoverHovered option background
Color – Option Selected--color-primary-100Currently selected option highlight
Color – Placeholder--color-text-mutedPlaceholder text color
Color – Error--color-error-600Error 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-lgDropdown panel elevation. Use Shadow Generator.
Typography--font-size-smOption 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

StateVisual ChangeBehavior
DefaultOutlined trigger with placeholder or selected valueReady for interaction
HoverBorder darkens or background subtly shiftsCursor changes to pointer
FocusFocus ring visible (2px, high contrast). Border color changes to primary.Dropdown does NOT open on focus alone — wait for click or Enter/Space.
OpenDropdown visible below (or above, if near viewport edge). Active option highlighted.Focus moves to the option list.
Option HoverBackground highlight on the hovered optionVisual feedback
Option SelectedCheckmark icon or primary-tinted background on the selected optionPersists after dropdown closes
DisabledReduced opacity (0.5). No interaction.aria-disabled="true" on the trigger
ErrorRed border, error icon, error message belowTriggered by form validation
LoadingSpinner inside the trigger or in the dropdownFor async option loading

Accessibility

WCAG Requirements

CriterionLevelRequirement
SC 1.3.1 Info and RelationshipsASelect must be associated with a visible <label> via for/id
SC 4.1.2 Name, Role, ValueACustom selects need role="listbox" on the option list and role="option" on each option
SC 2.1.1 KeyboardAAll interactions must work via keyboard
SC 1.4.11 Non-text ContrastAATrigger 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

KeyTrigger FocusedListbox Open
Enter / SpaceOpens the listboxSelects the focused option, closes listbox
Arrow DownOpens listbox (or moves focus to next option if open)Moves focus to next option
Arrow UpOpens 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-aheadOpens and jumps to matching optionJumps to matching option

Native vs. Custom: Accessibility Trade-offs

AspectNative <select>Custom Select
Screen reader supportPerfect, zero effortRequires meticulous ARIA
Keyboard navigationBuilt-inMust implement fully
Mobile experienceNative picker (excellent)Custom dropdown (okay)
Styling controlMinimalFull
Development effortMinimalSignificant

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

html
<!-- 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>
tsx
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

FeatureMaterial 3Shadcn/uiRadixAnt Design
ComponentSelect (Outlined/Filled)Select (Radix-based)Select primitiveSelect, AutoComplete
Custom renderingLimitedFull via RadixFulloptionRender prop
Search/filterExposed Dropdown MenuNot built-in (use Combobox)Not built-inshowSearch prop
Multi-selectNot nativeNot native (use multi-combobox)Not nativemode="multiple"
Option groupsSupportedSupported via SelectGroupSelect.GroupOptGroup
PositioningPopperRadix PopperConfigurable (popper or item-aligned)Dropdown aligns to trigger
Native fallbackNoHidden native <select> for form submissionHidden native <select>No
AccessibilityMaterial standardsRadix handles ARIA fullyExcellent — full ARIA listboxBasic

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.

Data InputDropdownPickerListbox