Form Controls
Checkbox, Radio, Switch, and Select — the core building blocks for capturing user preferences and input. All controls support keyboard navigation, focus rings, error states, and three sizes.
Checkbox
Three states: unchecked, checked, and indeterminate. Supports label, helperText, error, and five sizes. Indeterminate is set via an imperative ref effect — the checkbox remains a standard HTML input.
Checkbox group with indeterminate
A parent checkbox can reflect partial selection using the indeterminate prop. Drive it from your own state — Checkbox does not manage children.
Receive product updates and promotions
Radio
Wrap Radio elements inside RadioGroup to share name, controlled value, and size. Orientation can be vertical or horizontal.
Switch
Binary on/off control. Uses role="switch" on the underlying input. Supports controlled and uncontrolled usage. The sliding animation is a CSS transition on the thumb position — no JS animation library needed.
Select
Native <select> with a custom chevron overlay. Uses appearance: none to strip the OS chrome and applies the same size tokens as Input.
Please select a valid option
Sizes
All four controls share the same three-size scale so they compose naturally in forms.
| Size | Control height | Font size | Use case |
|---|---|---|---|
sm | 32px (Select) | 12px | Dense tables, toolbars |
md | 40px (Select) | 13px | Default — most forms |
lg | 48px (Select) | 15px | Hero forms, onboarding |
Implementation
All controls are client components that wrap native HTML inputs. Styling is achieved with CSS peer selectors — the native input is visually hidden via sr-only but remains in the accessibility tree and receives focus normally.
import { Checkbox } from "@/components/ui/Checkbox";
import { Radio, RadioGroup } from "@/components/ui/Radio";
import { Switch } from "@/components/ui/Switch";
import { Select } from "@/components/ui/Select";
// Checkbox
<Checkbox label="Accept terms" />
<Checkbox label="Partial selection" indeterminate />
<Checkbox label="With helper" helperText="We'll never share your data" />
<Checkbox label="With error" error="This field is required" />
// Radio group
<RadioGroup name="plan" value={plan} onChange={setPlan}>
<Radio value="starter" label="Starter" helperText="Up to 3 projects" />
<Radio value="pro" label="Pro" helperText="Unlimited projects" />
</RadioGroup>
// Switch
<Switch label="Push notifications" checked={on} onChange={() => setOn(!on)} />
// Select
<Select label="Region" placeholder="Choose a region…">
<option value="us-east">US East</option>
<option value="eu-west">EU West</option>
</Select>Checkbox props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label rendered next to the checkbox. |
helperText | string | — | Secondary description shown below the label. |
error | string | — | Error message — replaces helperText and turns the border red. |
size | "sm" | "md" | "lg" | "md" | Controls box size and font size. |
indeterminate | boolean | false | Sets the checkbox to a mixed/partial state. |
disabled | boolean | false | Prevents interaction and reduces opacity. |
RadioGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | HTML name attribute shared across all child Radio inputs. Required. |
value | string | — | Controlled selected value. |
onChange | (value: string) => void | — | Called with the value of the newly selected radio. |
orientation | "vertical" | "horizontal" | "vertical" | Stack direction for the radio options. |
size | "sm" | "md" | "lg" | "md" | Propagated to all child Radio elements. |
label | string | — | Group label rendered as a <legend>. |
Radio props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The value submitted when this radio is selected. Required. |
label | string | — | Visible label for the radio option. |
helperText | string | — | Secondary text shown below the label. |
size | "sm" | "md" | "lg" | "md" | Inherits from RadioGroup if not set. |
Switch props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Text label rendered to the right of the track. |
helperText | string | — | Secondary description shown below. |
size | "sm" | "md" | "lg" | "md" | Controls track and thumb dimensions. |
checked | boolean | — | Controlled on/off state. |
defaultChecked | boolean | — | Uncontrolled initial state. |
onChange | ChangeEventHandler | — | Native input change handler. |
disabled | boolean | false | Prevents interaction and reduces opacity. |
Select props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label rendered above the select. |
helperText | string | — | Secondary hint shown below. |
error | string | — | Error message — replaces helperText and turns the border red. |
size | "sm" | "md" | "lg" | "md" | Matches Input size tokens. |
placeholder | string | — | Disabled first option used as a placeholder. |
Mobile
Form controls require larger tap targets and clear visual states on touch screens. The Switch pattern in particular benefits from native OS conventions on iOS and Android.
Preferences
General
Dark mode
System default
Notifications
Contact method
| Scenario | Guidance |
|---|---|
| Touch targets | Checkbox and radio hit areas should be at least 44×44px even if the visual indicator is smaller. Use a transparent padding wrapper or an expanded after: pseudo-element. |
| Switch component | On iOS/Android, use the native UISwitch / SwitchCompat rather than a custom control — users expect the system toggle appearance and snap physics. |
| Radio group layout | Vertical stacked radio groups work better than horizontal on mobile. Keep labels on the same line as the control; avoid wrapping label text to a second line. |
| Checkbox in lists | In selection lists, make the entire row tappable (full-width touch target), not just the checkbox circle. This dramatically reduces missed taps. |
| Form spacing | Increase vertical gap between controls on mobile — use gap-4 or gap-5 instead of gap-2. Tight forms are hard to use with fat fingers and on-screen keyboards. |
Accessibility
- →All controls use native <input> elements — browser-native keyboard behavior, focus management, and form submission work without extra code.
- →Labels are associated via htmlFor/id — clicking the label activates the control.
- →RadioGroup renders a <fieldset> with <legend> to give screen readers the group name.
- →Switch uses role="switch" on the input so assistive technology announces it as a toggle.
- →The indeterminate state is set imperatively via ref.indeterminate so it is announced by screen readers as "mixed".
- →Error messages are associated with their input via aria-describedby when using the error prop.
- →Focus rings use ring-offset so they are always visible regardless of background color.