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
<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
TextInputinstead. - 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 / Area | Status |
|---|---|
| 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
documentlistener), Escape, or focus leaving the field. - Native on device renders the menu inline, expanding the field’s own
layout — Figma’s
state=activesymbols 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’sBottomSheet, 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 toinline. It requires the@react-native-picker/pickerpeer dependency and is un-themeable: it takes no design tokens, ignores inverse surfaces, and follows the OS dark mode.isDisabled/isLoadingalso do not disable it on iOS, because aUIPickerViewhas 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>carryingrole="combobox"— the ARIA select-only combobox pattern — witharia-haspopup="listbox",aria-expanded, andaria-controls. The explicit role is what makesaria-activedescendantandaria-requiredvalid (neither is supported onrole=button). - The option list is
role="listbox"withrole="option"rows andaria-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-invalidfollow the exact same precedence asTextInput.
React Native
- Trigger:
accessibilityRole="button",accessibilityState={{ disabled, busy, expanded }}. The menu isaccessibilityRole="menu"containingaccessibilityRole="menuitem"rows withaccessibilityState={{ 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 (rovingtabIndex), 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. isLoadingblocks opening the menu on both platforms (unlikeTextInput, 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
ButtonHTMLAttributesminus the managed set, native forwardsPressablePropsminus the managed set.style/classNameare the Tier-1 visual omit on both — visuals are token-driven, with no escape hatch.
No releases yet.