Skip to content

Inputs

Input Select

Beta

A single-choice dropdown field with a label, optional leading icon and full validation states. Available on web (React) and native (React Native).

Sizes

Small

Dense UI, compact forms.

<SelectInput label="Small" size="sm" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Medium

The default size for most layouts.

<SelectInput label="Medium" size="md" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Large

<SelectInput label="Large" size="lg" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Extra large

<SelectInput label="Extra large" size="xl" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

XX large

<SelectInput label="XX large" size="xxl" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Rounded

Rounded

<SelectInput rounded label="Trade" options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Leading icon

With leading icon

<SelectInput label="Trade" leadingIcon={MaterialSearch} options={TRADES} value={null} onChange={() => {}} placeholder="Choose a trade" />

Validation states

Invalid

Pick a trade.
<SelectInput label="Trade" options={TRADES} value={null} onChange={() => {}} isInvalid errorMessage="Pick a trade." />

Valid

Border + label ink only — Figma's valid state has no check icon.

<SelectInput label="Trade" options={TRADES} value="plumber" onChange={() => {}} isValid />

Loading

The trigger is disabled while options are loading.

<SelectInput label="Trade" options={TRADES} value={null} onChange={() => {}} isLoading />

Disabled

<SelectInput label="Trade" options={TRADES} value="plumber" onChange={() => {}} isDisabled />

When to use

Use SelectInput when a user must choose exactly one value from a known, bounded list — trade, region, a status. It covers labels, a leading icon, and the full validation state set (invalid / valid / disabled), so most single-choice fields need no custom styling on top.

When not to use

  • Free text: use TextInput instead.
  • A short, always-visible set (2–5 options): consider a radio group once available — a dropdown hides the options behind an extra tap/click a radio group doesn’t need.
  • Multiple selections at once: not supported — this is a single-select only component. A multi-select variant isn’t in the current Figma spec.
  • Search/filter over a large option list: not supported in this v1 — the Figma spec has no search chrome. Revisit if a real use case appears.

Props

SelectInput shares one label / description / helper / validation contract (FieldProps) across both packages, exactly like TextInput.

Prop Type Default Description
label * string - Visible label and the accessible name of the control.
options * { label: string; value: string }[] - The options rendered in the dropdown, in order.
value * string | null - Controlled selected value — always controlled on both platforms. null = nothing selected.
onChange * (value: string) => void - Called with the selected option's value.
placeholder string - Shown when value is null.
description ReactNode - Rich secondary content under the label.
helperText string - Helper text below the field. Replaced by errorMessage when isInvalid.
errorMessage string - Error message below the field, shown when isInvalid.
isRequired boolean false Renders a visible "*" marker and wires aria-required/accessibilityState.
isDisabled boolean false Disables interaction. Wins over every other state.
isInvalid boolean false Error border + aria-invalid; reveals errorMessage in place of helperText. Loses to isDisabled.
isValid boolean false Success border + label ink only — no check icon. Loses to isInvalid (both lose to isDisabled).
size 'sm' | 'md' | 'lg' | 'xl' | 'xxl' 'md' Same scale as TextInput.
rounded boolean false Fully-rounded corners.
isLoading boolean false Trailing spinner; the trigger is disabled while loading, so it cannot open on either platform.
leadingIcon ComponentType<SVGProps<SVGSVGElement>> (web) / SVG asset import (native) - Same web-vs-native shape as the DS Icon component.
presentation 'inline' | 'sheet' | 'device' 'inline' Native only. How the list is shown: 'inline' overlays a floating list under the trigger, so nothing below the field moves (a deliberate deviation from Figma's state=active symbol, which grows the frame); 'sheet' opens the list in the DS BottomSheet, which brings its own scrim, title and backdrop-tap dismissal; 'device' keeps the DS trigger and hands the choosing to the platform's own option UI — the UIPickerView wheel in our BottomSheet on iOS, Material's dialog on Android; on web it falls back to 'inline'. The first two are a behavioural axis — both render the same rows. 'device' is different in kind: its option UI is un-themeable and needs the @react-native-picker/picker peer dependency.
id string - Web only. Generated via useId() if omitted.
accessibilityLabel / aria-label string label Native: accessibilityLabel. Web: aria-label.
testID / data-testid string - Sub-ids: -trigger, -menu, -sheet (native, sheet presentation), -spinner, -option-<value>.

Import

Web

import { SelectInput } from '@checkatrade/components-web';
import '@checkatrade/components-web/css';

Native

import { SelectInput } from '@checkatrade/components-native';

Basic usage

const [trade, setTrade] = useState<string | null>(null);

<SelectInput
  label="Trade"
  placeholder="Choose a trade"
  options={[
    { label: 'Plumber', value: 'plumber' },
    { label: 'Electrician', value: 'electrician' },
  ]}
  value={trade}
  onChange={setTrade}
/>;

Platform status

Platform / AreaStatus
Design (Figma) Beta
Web (React) Beta
Native (React Native) Beta
iOS (Swift) Planned
Android (Kotlin) Planned
Accessibility audit Planned

Both platforms are always controlled (value + onChange) — unlike TextInput, there is no uncontrolled path on either platform, since a null initial selection has no natural DOM/RN equivalent to an uncontrolled default.

The option-list open/close model differs by platform, not just by implementation detail:

  • Web overlays the menu (no portal) and dismisses it on outside click (a document listener), Escape, or focus leaving the field.
  • Native on device renders the menu inline, expanding the field’s own layout — Figma’s state=active symbols draw exactly that. With no portal-free equivalent to a DOM outside-click listener, it dismisses on the trigger losing focus (onBlur) instead: no outside-tap-to-close and no Escape key in that mode.
  • Native on react-native-web uses the same document-level outside-click and Escape model as the web package. Blur deliberately does not dismiss there: a mousedown on an option moves focus off the trigger before the option’s own press lands, so closing on blur unmounted the row mid-click and the selection was silently lost. Tapping an option row does not blur the trigger on a real device, which is why the same signal is correct there and wrong on web.
  • Native, presentation="sheet" sidesteps the question entirely: the list opens in the design system’s BottomSheet, which brings its own scrim, title, close button and backdrop-tap/Android-back dismissal. Prefer it where a real dismissal affordance matters more than literal Figma parity.
  • Native, presentation="device" hands the choosing to the platform: the wheel opens in our sheet on iOS (Done commits, dismissing discards) and in Material’s dialog on Android. On web it falls back to inline. It requires the @react-native-picker/picker peer dependency and is un-themeable: it takes no design tokens, ignores inverse surfaces, and follows the OS dark mode. isDisabled/isLoading also do not disable it on iOS, because a UIPickerView has no disabled state. Choose it only when matching the OS control matters more than looking like the design system.

Accessibility

Web

  • The trigger is a real <button> carrying role="combobox" — the ARIA select-only combobox pattern — with aria-haspopup="listbox", aria-expanded, and aria-controls. The explicit role is what makes aria-activedescendant and aria-required valid (neither is supported on role=button).
  • The option list is role="listbox" with role="option" rows and aria-selected.
  • Full keyboard navigation: ArrowUp/ArrowDown open the list and move the cursor (clamping at both ends), Home/End jump to the first/last option, Enter/Space commit the highlighted option, Escape closes, and Tab moves focus out and closes. Focus stays on the trigger throughout; the listbox is pointed at via aria-activedescendant, so option rows are not tab stops.
  • aria-describedby/aria-required/aria-invalid follow the exact same precedence as TextInput.

React Native

  • Trigger: accessibilityRole="button", accessibilityState={{ disabled, busy, expanded }}. The menu is accessibilityRole="menu" containing accessibilityRole="menuitem" rows with accessibilityState={{ selected }}.
  • Keyboard (react-native-web only) — the rows are menuitems, so this follows the ARIA menu pattern, not the combobox model web uses: ArrowUp/Down open the menu and move between rows (clamping at both ends), Home/End jump to the first/last row, Enter/Space commit the focused row, Escape closes and returns focus to the trigger, and Tab leaves the menu rather than walking its rows. Focus moves onto the row itself (roving tabIndex), so exactly one row is a tab stop.
  • No arrow keys on a real device — a touch device has no keyboard, and VoiceOver/TalkBack navigate by their own gestures; rows are individually focusable for that.
  • No portal — see “Platform status” above for the resulting dismiss-model difference from web, and presentation="sheet" for the alternative.

Both platforms

  • Tone is never the only signal: invalid/valid states pair a border colour change with visible copy (errorMessage) or label ink, never colour alone.
  • isLoading blocks opening the menu on both platforms (unlike TextInput, which stays editable while loading) — there is no in-progress-with-stale-options state to show, so this is treated the same as “nothing to pick from yet”.
  • Both platforms spread their platform’s own props (Tier-1): web forwards ButtonHTMLAttributes minus the managed set, native forwards PressableProps minus the managed set. style/className are the Tier-1 visual omit on both — visuals are token-driven, with no escape hatch.

No releases yet.

Select a brand to demo:
Active brand: checkatrade