Skip to content
Caseroom

Components · Forms

Select

A native select in the field style, with groups, a placeholder and disabled options. No JavaScript.

39% of the 2,062 sites in The UI Index have a form (806 sites).

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/select.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/select.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.

select.tsx
import type { SelectHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { CHEVRON_PATH, select, type SelectGroup, type SelectOption } from './select.variants';

export type { SelectGroup, SelectOption };

export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> & {
  options: (SelectOption | SelectGroup)[];
  /** A first, empty option that asks for a choice ("Choose a region"). */
  placeholder?: string;
};

const isGroup = (o: SelectOption | SelectGroup): o is SelectGroup => 'options' in o;

/** A native <select> in the field style. Use `defaultValue` (uncontrolled) or `value` with `onChange`. */
export function Select({ options, placeholder, className, defaultValue, value, ...props }: SelectProps) {
  const initial = placeholder && value === undefined && defaultValue === undefined ? '' : defaultValue;
  return (
    <div className={select.root}>
      <select className={cn(select.field, className)} value={value} defaultValue={value === undefined ? initial : undefined} {...props}>
        {placeholder && <option value="" disabled>{placeholder}</option>}
        {options.map((o) =>
          isGroup(o) ? (
            <optgroup key={o.label} label={o.label}>
              {o.options.map((x) => <option key={x.value} value={x.value} disabled={x.disabled}>{x.label}</option>)}
            </optgroup>
          ) : (
            <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
          ),
        )}
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={select.chevron}><path d={CHEVRON_PATH} /></svg>
    </div>
  );
}
select.variants.ts
import { fieldClasses } from '../input/input.variants';

/** A native <select> in the field style: the platform's own list, keyboard and screen reader support, no JavaScript.
 *  The chevron is drawn over the right padding and ignores the pointer. */
export const select = {
  root: 'relative w-full',
  // While the empty placeholder option is chosen, the field reads as a placeholder (muted), not as a value.
  field: `${fieldClasses} h-9 cursor-pointer appearance-none py-1 pr-9 has-[option[value='']:checked]:text-muted-foreground [&_option]:text-foreground`,
  chevron: 'pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground',
};
export const CHEVRON_PATH = 'M4 6l4 4 4-4';

export type SelectOption = { value: string; label: string; disabled?: boolean };
export type SelectGroup = { label: string; options: SelectOption[] };