artui
Components

Select

View source

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 select

Playground

react
1 selected: react

Try Space / Enter to toggle, Backspace on the trigger to remove the last tag, Escape to close.

mode
groups
clear-all
disabled

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.

PropTypeDefaultDescription
multiplebooleanfalseWhen 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>.
valuestring (single) | readonly string[] (multi)noneControlled value. A single string in single mode; an array of strings in multi mode. Pair with onValueChange.
defaultValuestring (single) | readonly string[] (multi)"" / []Uncontrolled initial selection.
onValueChange(value: string) => void (single) | (value: readonly string[]) => void (multi)noneCalled whenever the selection changes.
namestringnoneHTML name attribute forwarded to the native <select> (single) or hidden <select multiple> (multi) for form submission.
openbooleannoneMulti mode only. When provided, puts the listbox into controlled-open mode. Pair with onOpenChange.
onOpenChange(open: boolean) => voidnoneMulti mode only. Called when the panel open state changes.
disabledbooleanfalseDisables the native select (single mode) or the trigger and all chip remove/clear-all buttons (multi mode).
aria-labelAccessibleTextnoneSingle mode only. Accessible name for the native <select>. Exactly one of aria-label or aria-labelledby is required: compile error otherwise.
aria-labelledbystringnoneSingle 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.

PropTypeDefaultDescription
aria-labelAccessibleTextnoneAccessible name for the trigger button inside the field. Exactly one of aria-label, aria-labelledby, or children is required: compile error otherwise.
aria-labelledbystringnoneID of an external element that labels the trigger button.
childrenAccessibleTextnoneVisible text label rendered inside the trigger button.
placeholderstring"Select options"Text shown inside the trigger button when nothing is selected. Hidden when chips are present.
removeLabel(label: string) => stringnoneCustomise the remove button's accessible name per chip. Defaults to `Remove ${label}`.
showClearAllbooleanfalseWhen 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.
clearAllLabelstring"Clear all selections"Accessible name for the clear-all button.
classNamestringnoneAdditional CSS class on the control field container.

Select.Content

Multi mode only. The listbox panel.

PropTypeDefaultDescription
childrenrequiredReactNodenoneSelect.Option and Select.Group elements. An empty Content triggers a dev overlay (WCAG 1.3.1).
classNamestringnoneAdditional 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.

PropTypeDefaultDescription
valuerequiredstringnoneUnique 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.
childrenrequiredReactNodenoneVisible label. Empty or whitespace-only children trigger a dev overlay (WCAG 1.3.1).
disabledbooleannoneMarks 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.
classNamestringnoneAdditional CSS class on the option element (multi mode custom panel only).

Select.Group

Works in both modes, placed alongside Option.

PropTypeDefaultDescription
labelrequiredstringnoneGroup 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).
disabledbooleannoneDisables 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.
childrenrequiredReactNodenoneSelect.Option elements inside this group.
classNamestringnoneAdditional 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

KeyAction
Enter+SpaceOpen the listbox and focus the first option
Arrow Down+Arrow UpOpen the listbox and focus the first option
BackspaceRemove the last chip; focus returns to the trigger when none are left
TabMove through chips, then the clear-all button, then the trigger

Listbox

KeyAction
Arrow DownFocus next option (stops at the last)
Arrow UpFocus previous option (stops at the first)
HomeFocus first option
EndFocus last option
Space+EnterToggle the focused option without closing
EscapeClose and return focus to the trigger
TabClose and move focus to the next element
A-ZTypeahead: 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

CriterionNameHow the component satisfies it
1.3.1(A)Info and RelationshipsSelected 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)KeyboardSingle 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 OrderFocus 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 VisibleFocus 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 InputToggling 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, ValueSingle 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 MessagesA 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 ContrastCSS 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.

On this page