Select
An accessible select with two modes. Pick one option from a styled native select, or several from a chip-based listbox. Both sit on real HTML elements, so forms and screen readers work without extra wiring.
npx artui@latest add selectPlayground
Try Space / Enter to toggle, Backspace on the trigger to remove the last tag, Escape to close.
Usage
The multiple prop picks the mode. Leave it off for a native <select>, or add it for the multi-select listbox. Either way, Option and Group describe the choices.
Single
import { Select } from '@/components/select';
<Select aria-label="Country" onValueChange={setCountry}>
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
<Select.Option value="fr">France</Select.Option>
</Select>This renders a real <select>, so the browser owns keyboard handling, focus, and form submission. Drop Option and Group straight into the root.
Single with groups
import { Select } from '@/components/select';
<Select aria-label="Country" name="country" onValueChange={setCountry}>
<Select.Group label="Benelux">
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
</Select.Group>
<Select.Option value="fr">France</Select.Option>
</Select>Group maps to <optgroup>. The name prop goes to the native select for form submission.
Multi
import { Select } from '@/components/select';
<Select multiple defaultValue={['be']} onValueChange={setCountries}>
<Select.Control aria-label="Countries" />
<Select.Content>
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
<Select.Option value="fr" disabled>France</Select.Option>
</Select.Content>
</Select>Control is the field: chips on the left, a trigger and caret pinned to the right. Content is the listbox that opens below it. A hidden <select multiple> mirrors the value for form submission.
Multi with groups
import { Select } from '@/components/select';
<Select multiple defaultValue={['be']} onValueChange={setCountries}>
<Select.Control aria-label="Countries" />
<Select.Content>
<Select.Group label="Benelux">
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
</Select.Group>
<Select.Group label="Other">
<Select.Option value="fr">France</Select.Option>
</Select.Group>
</Select.Content>
</Select>Screen readers announce the group name before its first option.
Clear-all
import { Select } from '@/components/select';
<Select multiple defaultValue={['be', 'nl']} onValueChange={setCountries}>
<Select.Control aria-label="Countries" showClearAll clearAllLabel="Clear all countries" />
<Select.Content>
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
</Select.Content>
</Select>The clear-all button shows only when something is selected. Pressing it empties the selection, announces "All selections cleared", and returns focus to the trigger.
Custom remove label
import { Select } from '@/components/select';
<Select multiple defaultValue={['be']} onValueChange={setCountries}>
<Select.Control aria-label="Pays" removeLabel={(label) => `Supprimer ${label}`} />
<Select.Content>
<Select.Option value="be">Belgique</Select.Option>
</Select.Content>
</Select>Each chip's remove button reads Remove {label} by default. Override removeLabel to translate it.
API
Select: single mode
Renders a styled native <select>. You must pass aria-label or aria-labelledby; leaving both off is a compile error.
| Prop | Type | Default | Description |
|---|---|---|---|
| multiple | boolean | false | When true, switches to multi-select mode: renders a hidden <select multiple> and a custom ARIA listbox UI with Control, Content, and optional Group subcomponents. When absent or false, renders a plain native <select>. |
| value | string (single) | readonly string[] (multi) | none | Controlled value. A single string in single mode; an array of strings in multi mode. Pair with onValueChange. |
| defaultValue | string (single) | readonly string[] (multi) | "" / [] | Uncontrolled initial selection. |
| onValueChange | (value: string) => void (single) | (value: readonly string[]) => void (multi) | none | Called whenever the selection changes. |
| name | string | none | HTML name attribute forwarded to the native <select> (single) or hidden <select multiple> (multi) for form submission. |
| open | boolean | none | Multi mode only. When provided, puts the listbox into controlled-open mode. Pair with onOpenChange. |
| onOpenChange | (open: boolean) => void | none | Multi mode only. Called when the panel open state changes. |
| disabled | boolean | false | Disables the native select (single mode) or the trigger and all chip remove/clear-all buttons (multi mode). |
| aria-label | AccessibleText | none | Single mode only. Accessible name for the native <select>. Exactly one of aria-label or aria-labelledby is required: compile error otherwise. |
| aria-labelledby | string | none | Single mode only. ID of an external element that labels the native <select>. |
Select.Control
Multi mode only. The field holding chips, the trigger, an optional clear-all button, and the caret. Needs an accessible name via aria-label, aria-labelledby, or children; omitting all three is a compile error.
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | AccessibleText | none | Accessible name for the trigger button inside the field. Exactly one of aria-label, aria-labelledby, or children is required: compile error otherwise. |
| aria-labelledby | string | none | ID of an external element that labels the trigger button. |
| children | AccessibleText | none | Visible text label rendered inside the trigger button. |
| placeholder | string | "Select options" | Text shown inside the trigger button when nothing is selected. Hidden when chips are present. |
| removeLabel | (label: string) => string | none | Customise the remove button's accessible name per chip. Defaults to `Remove ${label}`. |
| showClearAll | boolean | false | When true, renders a clear-all button at the right of the field (left of the caret). Only visible when at least one option is selected. |
| clearAllLabel | string | "Clear all selections" | Accessible name for the clear-all button. |
| className | string | none | Additional CSS class on the control field container. |
Select.Content
Multi mode only. The listbox panel.
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | none | Select.Option and Select.Group elements. An empty Content triggers a dev overlay (WCAG 1.3.1). |
| className | string | none | Additional CSS class on the listbox panel. |
Select.Option
Works in both modes. In single mode it sits in the root; in multi mode it sits inside Select.Content.
| Prop | Type | Default | Description |
|---|---|---|---|
| valuerequired | string | none | Unique identifier for this option. Duplicate values trigger a dev overlay (WCAG 4.1.2). In single mode maps to a native <option>; in multi mode maps to both a native <option> and an ARIA option div. |
| childrenrequired | ReactNode | none | Visible label. Empty or whitespace-only children trigger a dev overlay (WCAG 1.3.1). |
| disabled | boolean | none | Marks the option as disabled. In single mode sets the native disabled attribute. In multi mode sets aria-disabled and excludes the option from keyboard navigation. |
| className | string | none | Additional CSS class on the option element (multi mode custom panel only). |
Select.Group
Works in both modes, placed alongside Option.
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | none | Group heading. In single mode maps to <optgroup label>; in multi mode becomes role=group aria-label and a visible (aria-hidden) header. Empty label triggers a dev overlay (WCAG 1.3.1). |
| disabled | boolean | none | Disables the entire group. In single mode sets the disabled attribute on <optgroup>; in multi mode individual Option children must also be marked disabled for keyboard exclusion. |
| childrenrequired | ReactNode | none | Select.Option elements inside this group. |
| className | string | none | Additional CSS class on the group container (multi mode custom panel only). |
Keyboard
Single mode hands everything to the native <select>, so the keys below cover multi mode only.
Control
| Key | Action |
|---|---|
| Enter+Space | Open the listbox and focus the first option |
| Arrow Down+Arrow Up | Open the listbox and focus the first option |
| Backspace | Remove the last chip; focus returns to the trigger when none are left |
| Tab | Move through chips, then the clear-all button, then the trigger |
Listbox
| Key | Action |
|---|---|
| Arrow Down | Focus next option (stops at the last) |
| Arrow Up | Focus previous option (stops at the first) |
| Home | Focus first option |
| End | Focus last option |
| Space+Enter | Toggle the focused option without closing |
| Escape | Close and return focus to the trigger |
| Tab | Close and move focus to the next element |
| A-Z | Typeahead: jump to the first option starting with that letter |
Arrows stop at the ends rather than wrapping, per the APG Listbox pattern. A polite live region announces every change, like "Belgium added, 2 selected" or "All selections cleared".
Accessibility
| Criterion | Name | How the component satisfies it |
|---|---|---|
| 1.3.1(A) | Info and Relationships | Selected options are communicated outside the listbox via removable chips inline in the Control field; each chip's remove button has an accessible name derived from the option label (default: 'Remove <label>', customisable via removeLabel). A visually-hidden aria-live=polite region announces every selection change ('Belgium added, 3 selected') and clear-all ('All selections cleared'). Dev overlays fire for: empty Content, empty/whitespace Option labels, empty Group labels. Option groups are conveyed semantically as role=group with aria-label in the custom panel and <optgroup label> in the hidden native select. |
| 2.1.1(A) | Keyboard | Single mode: keyboard fully delegated to the native <select>. Multi mode: Arrow Down/Up move focus between options (no wrap, APG Listbox); Home/End jump to first/last; Space or Enter toggle selection without closing; Escape closes and returns focus to trigger; Tab closes naturally; printable-character typeahead jumps to first match; Backspace on the trigger removes the last chip. All selection changes route through onValueChange (onChange-driven, registry masked-input rule). |
| 2.4.3(A) | Focus Order | Focus moves to the first focusable option when the multi-mode panel opens. Escape and Tab return focus to the trigger. Clear-all moves focus to the trigger after clearing. When the last chip is removed (via remove button or Backspace), focus is rescued to the trigger. |
| 2.4.7(AA) | Focus Visible | Focus indicators: the Control field shows a :focus-within outline scoped to :has(.artui-select-trigger:focus-visible), so only keyboard focus (not mouse clicks) triggers the ring. Individual chip remove buttons and the clear-all button have :focus-visible outlines. All indicators are visible in Windows High Contrast Mode via the forced-colors @media block. |
| 2.5.5(AAA) | Target Size (Enhanced) | All interactive elements have a minimum 44px touch target: native select (min-height), Control field (min-height 44px), chip remove buttons (padding), clear-all button (padding). The trigger fills the remaining field width, enlarging the click area proportionally. |
| 3.2.2(A) | On Input | Toggling an option in multi mode never causes an unexpected context change: onValueChange is a local state update only and the panel stays open. |
| 4.1.2(A) | Name, Role, Value | Single mode: the native <select> carries role, name, and value automatically. Accessible name enforced at compile time. Multi mode: the trigger inside Control carries aria-haspopup=listbox, aria-expanded, aria-controls; accessible name enforced at compile time via AccessibleNameProps on Control. Custom panel carries role=listbox, aria-multiselectable=true, aria-labelledby. Each custom Option carries role=option and explicit aria-selected (true or false, never omitted). Hidden <select multiple> stays in sync for form submission. The Control container is a presentational div: no interactive nesting violations. Dev overlays fire for duplicate option values and empty content. |
| 4.1.3(AA) | Status Messages | A visually-hidden role=status region with aria-live=polite and aria-atomic=true announces all selection changes and clear-all to screen readers. |
| 1.4.11(AA) | Non-text Contrast | CSS uses system color tokens (Canvas, ButtonFace, Highlight, ButtonText) in the forced-colors @media block, preserving 3:1 non-text contrast in Windows High Contrast Mode. |
Do
Give every single-mode Select an accessible name
<Select aria-label="Country" onValueChange={setCountry}>
<Select.Option value="be">Belgium</Select.Option>
</Select>Omitting both aria-label and aria-labelledby is a compile error.
Name the Control in multi mode
<Select.Control aria-label="Countries" />
<Select.Control aria-labelledby="countries-label" />
<Select.Control>Countries</Select.Control>Pick one of the three. The type requires it.
Group long option lists
<Select.Content>
<Select.Group label="Benelux">
<Select.Option value="be">Belgium</Select.Option>
<Select.Option value="nl">Netherlands</Select.Option>
</Select.Group>
</Select.Content>Groups become role="group" in the panel and <optgroup> in the native select, so both the screen reader and the form get the structure.
Use name to submit values
<Select multiple name="countries" onValueChange={setCountries}>
<Select.Control aria-label="Countries" />
<Select.Content>
<Select.Option value="be">Belgium</Select.Option>
</Select.Content>
</Select>Multi-mode values submit as repeated fields, the same as a native <select multiple>.
Don't
Skip the accessible name in single mode
{/* compile error: aria-label or aria-labelledby required */}
<Select onValueChange={setCountry}>
<Select.Option value="be">Belgium</Select.Option>
</Select>Use Control or Content in single mode
{/* wrong: these belong to multi mode */}
<Select aria-label="Country" onValueChange={setCountry}>
<Select.Control aria-label="Country" />
<Select.Content>
<Select.Option value="be">Belgium</Select.Option>
</Select.Content>
</Select>In single mode, put Option and Group directly in the root.
Render empty Content
<Select.Content>{false}</Select.Content>A dev overlay fires (WCAG 1.3.1). An empty listbox leaves keyboard and screen reader users nothing to act on.
Repeat a value across options
<Select.Option value="react">React (stable)</Select.Option>
<Select.Option value="react">React (canary)</Select.Option>A dev guard fires (WCAG 4.1.2). Duplicate values make the selection ambiguous for the UI and assistive tech alike.
Use an empty Group label
<Select.Group label="">
<Select.Option value="be">Belgium</Select.Option>
</Select.Group>A dev overlay fires (WCAG 1.3.1). Without a label, screen readers can't identify the group.
DropdownMenu
Accessible dropdown menu following the WAI-ARIA APG Menu Button pattern. Supports single-level submenus, typeahead, full keyboard navigation, and compile-time enforcement of accessible names.
Slider
Accessible single-thumb and range slider following WAI-ARIA APG Slider patterns, with compile-time per-thumb name enforcement, automatic aria-valuetext, and dynamic dependent aria-valuemin/aria-valuemax.