Skip to content

Select

  • Picking one option from many in a form: country, language
  • A compact control where space is short
  • A choice that should use the system picker on mobile

Not when people search the list by typing or pick several — use a Combobox — or for up to five options that are worth seeing — use a RadioGroup.

Choosing between choosing one option or a value:

ComponentUse it forFor example
RadioGroupUp to about five options, all visible at once, as a form valueShipping speed, plan size
SelectOne option from a longer list, in little space, with the system pickerCountry, time zone, sort order in a form
ComboboxA long list people search by typing, several picks, or options loaded from a serverAssign reviewers, search a city, add tags
ToggleGroupA few options shown as buttons that apply at once, not a form fieldText alignment, list or grid view
SliderA number in a range, where dragging is easier than typing an exact valueVolume, brightness, price range

Select is a real <select>. The picker, keyboard, typeahead, validation and form submission are all the platform’s, so there is no headless half. On phones it opens the system picker. It works with react-hook-form’s register() as-is.

Pick a plan to continue.
import { Select } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
export default function SelectBasics() {
return (
<>
{/* The empty-value option is the prompt: required rejects it, and it reads muted. */}
<div style={field}>
<label htmlFor="select-country">Country</label>
<Select id="select-country" required defaultValue="">
<option value="" disabled>
Choose a country…
</option>
<option value="in">India</option>
<option value="jp">Japan</option>
<option value="gb">United Kingdom</option>
<option value="us">United States</option>
</Select>
</div>
<div style={field}>
<label htmlFor="select-plan">Plan</label>
<Select id="select-plan" defaultValue="" aria-invalid aria-describedby="select-plan-error">
<option value="">Choose a plan…</option>
<optgroup label="Monthly">
<option value="starter-monthly">Starter</option>
<option value="team-monthly">Team</option>
</optgroup>
<optgroup label="Yearly">
<option value="starter-yearly">Starter</option>
<option value="team-yearly">Team</option>
</optgroup>
</Select>
<small id="select-plan-error" style={{ color: 'var(--color-status-error)' }}>
Pick a plan to continue.
</small>
</div>
<div style={field}>
<label htmlFor="select-disabled">Disabled</label>
<Select id="select-disabled" defaultValue="read" disabled>
<option value="read">Read only</option>
<option value="write">Read and write</option>
</Select>
</div>
</>
);
}
<Select>{options}</Select>

Like Input, the <select> sits inside a <div class="select"> that draws the border and the chevron:

PropLands on
classNamethe box
children — your <option>s and <optgroup>sthe <select>
everything else, including id, ref, renderthe <select>

The chevron is drawn by the box rather than the browser, so it looks the same everywhere, and clicks on it fall through to the <select>.

<select> has no placeholder. Use an option with an empty value instead: while it is selected, the field text is muted, and required refuses to submit it. Add disabled to it so it can’t be picked again once a real choice is made.

<Select required defaultValue="">
<option value="" disabled>
Choose a country…
</option>
…
</Select>

There is no search box. A native <select> can’t hold one, and building one would give up the system picker and everything else above. What it does have is typeahead: with the field focused, type the start of an option to jump to it.

That covers short lists well. For long lists where users need to filter, a combobox is the right component; it will come after Popover, since it needs a positioned popup.

multiple, or a size above 1, turns the select into a list box: the options show in place, the chevron goes, and the field sets its own height.

Hold Ctrl or ⌘ to pick more than one.
import { Select } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
export default function SelectMultiple() {
return (
<div style={field}>
<label htmlFor="select-toppings">Toppings</label>
{/* multiple turns the select into a list box: no chevron, options shown in place. */}
<Select
id="select-toppings"
multiple
size={5}
defaultValue={['basil', 'olives']}
aria-describedby="select-toppings-hint"
>
<option value="basil">Basil</option>
<option value="mushroom">Mushroom</option>
<option value="olives">Olives</option>
<option value="onion">Onion</option>
<option value="pepper">Pepper</option>
<option value="pineapple">Pineapple</option>
</Select>
<small id="select-toppings-hint" style={{ color: 'var(--color-text-muted)' }}>
Hold Ctrl or ⌘ to pick more than one.
</small>
</div>
);
}

Consider checkboxes

A multi-select list box needs Ctrl or ⌘ to pick more than one, which many users don’t know. For a handful of options, a set of Checkboxes is easier to use.

All of them come from the native control, read with :has(). There is no data-* attribute to set:

StateSet it with
focusthe platform (:focus-visible)
invalidaria-invalid on the select
disableddisabled on the select
Select props
PropTypeDefaultDescription
childrenReactNodeThe <option> and <optgroup> elements, as for a native <select>. An option with value="" selected reads as a placeholder.
classNamestringGoes on the box that frames the select and its chevron, not on the <select>. Every other prop, ref and render included, goes on the <select>.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • Controlled use works as with any <select>: value and onChange, where onChange fires as soon as the choice changes.
  • The open list is drawn by the browser and operating system, so its look varies by platform. Only the closed field is styled.
  • Restyle every select with the --select-* tokens, e.g. --select-height or --select-icon-color.