React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

selectdropdownformpickercomponenttailwind

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.

Use Cases

  • Country or region pickers on address forms
  • Category filters on product listing pages
  • Role selection in user management panels
  • Sort-by controls on data tables
  • Language or locale switchers
  • Priority or status selectors in task management
  • Time zone pickers in scheduling interfaces

Simplest Implementation

"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.

Variations

Native Select with Label

"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.

With Placeholder

"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.

With Option Groups

"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.

Multi-Select (Custom)

"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.

Searchable / Filterable

"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.

Controlled with Error State

"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.

Complex Implementation

"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:

  • forwardRef -- allows parent components to attach refs for focus management and imperative control of the trigger button.
  • Keyboard navigation -- supports ArrowUp, ArrowDown, Enter, and Escape keys with a highlighted index tracker. This makes the custom select accessible without relying on native <select> behavior.
  • ARIA combobox pattern -- the trigger uses role="combobox", aria-expanded, aria-haspopup="listbox", and aria-controls to communicate the dropdown state to assistive technology.
  • Searchable mode -- when 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.
  • Option groups support -- accepts either flat options or structured groups, flattened internally with useMemo so filtering and keyboard navigation work uniformly.
  • Disabled options -- individual options can be disabled with aria-disabled and a grayed-out style, preventing selection while remaining visible in the list.
  • Click-outside dismissal -- a mousedown listener closes the dropdown when clicking outside the container, cleaned up on unmount to prevent memory leaks.

Gotchas

  • 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.

  • Input -- Text input with shared form styling conventions
  • Dropdown -- General-purpose dropdown menu for actions
  • Button -- Trigger buttons used with custom selects
  • Forms -- Form patterns and validation strategies
  • Event Handling -- onChange and event typing