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.

Billing plan

Up to 3 projects, 1 GB storage

Unlimited projects, 50 GB storage

Custom limits, SSO, dedicated support

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.

SizeControl heightFont sizeUse case
sm32px (Select)12pxDense tables, toolbars
md40px (Select)13pxDefault — most forms
lg48px (Select)15pxHero 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.

FormControls.tsx
tsx
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

PropTypeDefaultDescription
labelstringVisible label rendered next to the checkbox.
helperTextstringSecondary description shown below the label.
errorstringError message — replaces helperText and turns the border red.
size"sm" | "md" | "lg""md"Controls box size and font size.
indeterminatebooleanfalseSets the checkbox to a mixed/partial state.
disabledbooleanfalsePrevents interaction and reduces opacity.

RadioGroup props

PropTypeDefaultDescription
namestringHTML name attribute shared across all child Radio inputs. Required.
valuestringControlled selected value.
onChange(value: string) => voidCalled 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.
labelstringGroup label rendered as a <legend>.

Radio props

PropTypeDefaultDescription
valuestringThe value submitted when this radio is selected. Required.
labelstringVisible label for the radio option.
helperTextstringSecondary text shown below the label.
size"sm" | "md" | "lg""md"Inherits from RadioGroup if not set.

Switch props

PropTypeDefaultDescription
labelstringText label rendered to the right of the track.
helperTextstringSecondary description shown below.
size"sm" | "md" | "lg""md"Controls track and thumb dimensions.
checkedbooleanControlled on/off state.
defaultCheckedbooleanUncontrolled initial state.
onChangeChangeEventHandlerNative input change handler.
disabledbooleanfalsePrevents interaction and reduces opacity.

Select props

PropTypeDefaultDescription
labelstringVisible label rendered above the select.
helperTextstringSecondary hint shown below.
errorstringError message — replaces helperText and turns the border red.
size"sm" | "md" | "lg""md"Matches Input size tokens.
placeholderstringDisabled 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.

9:41

Preferences

General

Dark mode

System default

Notifications

Contact method

ScenarioGuidance
Touch targetsCheckbox 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 componentOn iOS/Android, use the native UISwitch / SwitchCompat rather than a custom control — users expect the system toggle appearance and snap physics.
Radio group layoutVertical 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 listsIn selection lists, make the entire row tappable (full-width touch target), not just the checkbox circle. This dramatically reduces missed taps.
Form spacingIncrease 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.