Select

Developer Experience (DX)

How developers use the Select, with one typed list of options, a value that works bound or unbound, and a slot for each part.

Next, let's look at how developers use the Select. You give it a list of options and a name, put it in a Field, and bind its value only when your code needs to read it.

import { useState } from "react";
import { Field } from "@/components/Field";
import { Select } from "@/components/Select";

const countries = [
  { value: "fr", label: "France" },
  { value: "es", label: "Spain" },
  { value: "nz", label: "New Zealand" },
];

export function Shipping() {
  const [country, setCountry] = useState<string>();

  return (
    <Field label="Country">
      <Select id="country" name="country" options={countries} placeholder="Choose a country" value={country} onChange={(event) => setCountry(event.target.value)} />
    </Field>
  );
}

Consistent

The Select uses the names the other form controls use, so knowing one means knowing them all: the same size scale as the Input, disabled, readonly, required, placeholder and name, and aria-invalid rather than an invalid prop. Like the Input, classes and styles go on the root, which draws the field, and everything else on the control, including id and ARIA. Unlike the Input, the Select puts name, form and autocomplete on the <select> in both renderings, where change and input fire too, since that's what forms read.

NameKindWhat it means
sizePropxs, sm, md, lg or xl, as on the Input
disabledPropThe Select can't be used, and leaves its form
readonlyPropThe value shows and submits, but can't change
requiredPropThe form needs a value, which the Field sets
placeholderPropThe text shown while nothing is picked
namePropThe name the value submits under
leadingSlotAn icon before the value
changeEventThe value changed

nativeOnTouch is the one name the other controls don't have, so it says when it applies and what it does. Like the Popover's arrow, it's on by default, and you turn it off with false.

Type-safe

The props, the options, the value and every slot are typed, so your editor can autocomplete them and your type checker catches mistakes like size="medium".

PropTypeDefaultDescription
optionsSelectItem[]Required. The options, groups and separators people pick from
valuestring | string[]The value, bound or not, as an array with multiple
multiplebooleanfalsePicks any number of values, each shown as a tag
placeholderstringShown while nothing is picked, and never a value
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the Select is
disabledbooleanfalseSets disabled on both controls, and leaves its form
readonlybooleanfalseKeeps the value in its form, and turns off nativeOnTouch
requiredbooleanfalseAsks for a value, and the Field sets it
nativeOnTouchbooleantrueShows the native <select> on a coarse primary pointer
removeLabel(label: string) => string`Remove ${label}`A tag's remove button's name
removedLabel(label: string) => string`${label} removed`What screen readers hear after a removal
countLabel(count: number) => string`${count} selected`The value read out past three
EventPayloadDescription
changeEventFires from the <select>, and bubbles, once a pick in either rendering changes the value
inputEventFires from the <select>, and bubbles, right before change

We also export the types for data-driven forms: SelectOption, SelectGroup, SelectSeparator, SelectItem and SelectProps.

import type { SelectItem, SelectOption, SelectProps } from "@/components/Select";

const items: SelectItem[] = [
  { value: "fr", label: "France" },
  { separator: true },
  { label: "Oceania", options: [{ value: "nz", label: "New Zealand" }] },
];

An item with a value is an option, one with options is a group, and one with separator is a separator. A few names differ by framework, each for that framework's reason. React and Solid pass the value slot as renderValue, since value is already the value, and group-label as groupLabel. Angular puts id and name on the <select> inside its directive, so the label's for finds a real control, and uses (ngModel) for the value.

SlotReceivesNative rendering
leadingNothingHonoured
valueselected, the picked optionsNot honoured
arrowNothingHonoured
optionoption, selected and activeNot honoured
tagoptionNot honoured
group-labellabelNot honoured
separatorNothingNot honoured
iconoption, shown in the panel, the field and tagsNot honoured

Composable

Put the Select in a Field, and it gets its label, hint, error, required, disabled and size without any wiring. It takes the same text-control path through the Field as an Input does, in both renderings, since it's the same component either way.

The slots let you change what's inside each part without giving up how it behaves. The leading, arrow and separator slots take content, while value, option, tag, group-label and icon hand you what to render, as the slot table shows. The option slot keeps the check mark beside what you render, and the tag slot keeps the remove button.

<Select id="country" name="country" options={countries} nativeOnTouch={false}
  leading={<GlobeIcon />}
  renderValue={({ selected }) => <>Ship to {selected[0]?.label}</>}
  option={({ option, selected }) => <>{option.label}{selected && " (yours)"}</>}
/>

Controllable

Bind the value when your code reads it, and leave it unbound when the form is all you need. The <select> holds it either way, so an unbound Select still submits and resets.

// Controlled: your state decides.
<Select id="country" name="country" options={countries} value={country} onChange={(event) => setCountry(event.target.value)} />
// Uncontrolled: the Select keeps its own, and its form submits it.
<Select id="country" name="country" options={countries} defaultValue="fr" />

change and input fire from the <select>, so a listener on the Select or on its form hears both a picker choice and a selection in the panel, with the value already updated. A ref gets you both controls, through control and select, for focusing it from your own code.

DX Checklist

RuleLevelScopeRequirementBasisCheck
select/options-propMustComponentOptions, groups and separators are one typed options list, the same in both renderingsOpen ComponentsUnit test, Type check
select/valueMustComponentThe value is a string, or an array with multiple, bound or unboundOpen ComponentsUnit test
select/typedShouldComponentIts props and slots are typed, and the option types are exportedOpen ComponentsType check
select/same-namesShouldComponentIt uses the names and sizes the other form controls useOpen ComponentsReview
select/attributesMustComponentClasses and styles go on the root, form attributes on the <select>, and the rest on the active controlOpen ComponentsUnit test
select/in-a-fieldMustBothIn a Field, it takes the label, description, invalid and disabled state in both renderingsWCAG 1.3.1 (A)Unit test
select/slotsShouldComponentEach part has a slot or tokens to restyle it, and the check mark and remove button stayOpen ComponentsUnit test
select/translatableShouldComponentEvery string it announces or adds comes through a propOpen ComponentsUnit test
Copyright © 2026