Form components
This chapter builds Select, Checkbox, CheckboxGroup, RadioGroup, and Switch. Each one follows the React Aria hook pattern from the previous chapter. The implementations get progressively more complex: Select involves a popover, Checkbox supports indeterminate state, and RadioGroup handles arrow key navigation within a group.
Select
Select is the most complex form component in the library. It combines multiple React Aria hooks: useSelect for the trigger button, useListBox for the options list, useOption for each individual option, and usePopover for the floating panel that contains the list. It also uses useSelectState from react-stately, which manages the open/closed state of the popover and tracks the currently selected option.
// src/components/Select/Select.tsx (simplified structure)
// Note: this example references Popover, ListBox, ChevronIcon, and DismissButton
// as helper components internal to the Select implementation. The companion
// repository contains the complete implementations. Here we focus on the hook
// wiring pattern.
import { useRef } from 'react'
import { useSelectState, type SelectState } from 'react-stately'
import {
useSelect,
useButton,
useListBox,
useOption,
usePopover,
HiddenSelect,
} from 'react-aria'
import type { RefObject } from 'react'
import type { Key } from 'react-aria'
import { cn } from '@/utils/cn'
import './select.css'
export interface SelectProps<T extends object> {
label: string
items: Iterable<T>
children: (item: T) => React.ReactNode
selectedKey?: Key | null
defaultSelectedKey?: Key | null
onSelectionChange?: (key: Key | null) => void
placeholder?: string
description?: string
errorMessage?: string
isRequired?: boolean
isDisabled?: boolean
className?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function ChevronIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
)
}
interface PopoverInternalProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state: SelectState<any>
triggerRef: RefObject<HTMLButtonElement | null>
popoverRef: RefObject<HTMLDivElement | null>
children: React.ReactNode
}
function Popover({
state,
triggerRef,
popoverRef,
children,
}: PopoverInternalProps) {
const { popoverProps } = usePopover({ triggerRef, popoverRef }, state)
return (
<div
{...popoverProps}
ref={popoverRef}
className="rudiment-select__popover"
>
{children}
</div>
)
}
interface ListBoxInternalProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state: SelectState<any>
listBoxRef: RefObject<HTMLUListElement | null>
[key: string]: unknown
}
function ListBox({ state, listBoxRef, ...props }: ListBoxInternalProps) {
const { listBoxProps } = useListBox(props, state, listBoxRef)
return (
<ul {...listBoxProps} ref={listBoxRef} className="rudiment-select__listbox">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
)
}
interface OptionInternalProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
item: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state: SelectState<any>
}
function Option({ item, state }: OptionInternalProps) {
const optionRef = useRef<HTMLLIElement>(null)
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
optionRef,
)
return (
<li
{...optionProps}
ref={optionRef}
className="rudiment-select__option"
data-focused={isFocused}
aria-selected={isSelected}
>
{item.rendered}
</li>
)
}
export function Select<T extends object>(props: SelectProps<T>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = useSelectState(props as any)
const triggerRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const listBoxRef = useRef<HTMLUListElement>(null)
const {
labelProps,
triggerProps,
valueProps,
menuProps,
descriptionProps,
errorMessageProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} = useSelect(props as any, state, triggerRef)
const { buttonProps } = useButton(triggerProps, triggerRef)
return (
<div className={cn('rudiment-select', props.className)}>
<label {...labelProps} className="rudiment-select__label">
{props.label}
</label>
<HiddenSelect state={state} triggerRef={triggerRef} label={props.label} />
<button
{...buttonProps}
ref={triggerRef}
className={cn(
'rudiment-select__trigger',
props.errorMessage && 'rudiment-select__trigger--error',
)}
>
<span {...valueProps}>
{state.selectedItem
? state.selectedItem.rendered
: props.placeholder || 'Select an option'}
</span>
<ChevronIcon aria-hidden="true" />
</button>
{state.isOpen && (
<Popover state={state} triggerRef={triggerRef} popoverRef={popoverRef}>
<ListBox {...menuProps} state={state} listBoxRef={listBoxRef} />
</Popover>
)}
{props.description && !props.errorMessage && (
<p {...descriptionProps} className="rudiment-select__description">
{props.description}
</p>
)}
{props.errorMessage && (
<p {...errorMessageProps} className="rudiment-select__error">
{props.errorMessage}
</p>
)}
</div>
)
}
The HiddenSelect component from React Aria renders a hidden native <select> element for form submission compatibility. Unlike the hooks imported alongside it, HiddenSelect is a React component — the first time a component is imported directly from react-aria in this guide rather than following the hook-only pattern. The visible trigger is a <button> that opens the popover. The listbox inside the popover handles arrow key navigation, typeahead (type a letter to jump to matching options), and Home/End to jump to the first/last option.
This is a lot of code for a select input. That's the point. Getting keyboard navigation, ARIA attributes, popover positioning, and focus management right for a select is genuinely difficult. React Aria handles the behavioral complexity so your component code is mostly rendering and styling.
Checkbox and CheckboxGroup
useToggleState from react-stately tracks a single boolean on/off value — it's the state layer for any component that toggles between selected and not selected. Checkbox and Switch both use it.
// src/components/Checkbox/Checkbox.tsx
import { forwardRef } from 'react'
import { useCheckbox } from 'react-aria'
import { useObjectRef } from '@react-aria/utils'
import { useToggleState } from 'react-stately'
import { cn } from '@/utils/cn'
export interface CheckboxProps {
children: React.ReactNode
isSelected?: boolean
defaultSelected?: boolean
isIndeterminate?: boolean
onChange?: (isSelected: boolean) => void
isDisabled?: boolean
value?: string
className?: string
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(props, forwardedRef) {
const ref = useObjectRef(forwardedRef)
const state = useToggleState(props)
const { inputProps } = useCheckbox(props, state, ref)
return (
<label
className={cn(
'rudiment-checkbox',
props.isDisabled && 'rudiment-checkbox--disabled',
props.className,
)}
>
<input {...inputProps} ref={ref} className="rudiment-checkbox__input" />
<span
className={cn(
'rudiment-checkbox__control',
state.isSelected && 'rudiment-checkbox__control--checked',
props.isIndeterminate &&
'rudiment-checkbox__control--indeterminate',
)}
aria-hidden="true"
/>
<span className="rudiment-checkbox__label">{props.children}</span>
</label>
)
},
)
The hidden native <input> provides the actual checkbox behavior. The rudiment-checkbox__control span is the visual indicator, styled via CSS to show a checkmark, dash (indeterminate), or empty box. The aria-hidden="true" on the visual indicator prevents screen readers from announcing it twice (once for the hidden input, once for the visual).
Using forwardRef here lets a parent component call .focus() on the underlying input directly, which matters when you need to programmatically focus a checkbox, for example after form validation reveals an error. The React Aria hooks expect a RefObject<T>, but forwardRef gives you a ForwardedRef<T>, which can be either an object ref or a callback ref. useObjectRef from @react-aria/utils normalizes both forms into the RefObject the hooks need, so you get full compatibility without extra boilerplate. @react-aria/utils is already a transitive dependency of react-aria, so no new package is required.
CheckboxGroup wraps multiple checkboxes with a group label. It uses useCheckboxGroupState from react-stately, which manages the array of currently selected values across the group.
// src/components/Checkbox/CheckboxGroup.tsx
import { useCheckboxGroup } from 'react-aria'
import { useCheckboxGroupState } from 'react-stately'
import { cn } from '@/utils/cn'
export interface CheckboxGroupProps {
label: string
description?: string
value?: string[]
defaultValue?: string[]
onChange?: (value: string[]) => void
isDisabled?: boolean
errorMessage?: string
children: React.ReactNode
className?: string
}
export function CheckboxGroup(props: CheckboxGroupProps) {
const state = useCheckboxGroupState(props)
const { groupProps, labelProps, descriptionProps, errorMessageProps } =
useCheckboxGroup(props, state)
return (
<div
{...groupProps}
className={cn('rudiment-checkbox-group', props.className)}
>
<span {...labelProps} className="rudiment-checkbox-group__label">
{props.label}
</span>
{props.children}
{props.description && !props.errorMessage && (
<p
{...descriptionProps}
className="rudiment-checkbox-group__description"
>
{props.description}
</p>
)}
{props.errorMessage && (
<p {...errorMessageProps} className="rudiment-checkbox-group__error">
{props.errorMessage}
</p>
)}
</div>
)
}
RadioGroup
RadioGroup has an important keyboard behavior difference from individual checkboxes: arrow keys move the selection between radio options within the group, while Tab moves focus into and out of the group (not between options). React Aria handles this automatically. useRadioGroupState from react-stately tracks the currently selected value within the group.
// src/components/RadioGroup/RadioGroup.tsx
import { useRadioGroup } from 'react-aria'
import { useRadioGroupState } from 'react-stately'
import { cn } from '@/utils/cn'
export interface RadioGroupProps {
label: string
description?: string
value?: string
defaultValue?: string
onChange?: (value: string) => void
orientation?: 'horizontal' | 'vertical'
isDisabled?: boolean
errorMessage?: string
children: React.ReactNode
className?: string
}
export function RadioGroup(props: RadioGroupProps) {
const state = useRadioGroupState(props)
const { radioGroupProps, labelProps, descriptionProps, errorMessageProps } =
useRadioGroup(props, state)
return (
<div
{...radioGroupProps}
className={cn('rudiment-radio-group', props.className)}
>
<span {...labelProps} className="rudiment-radio-group__label">
{props.label}
</span>
<div
className={cn(
'rudiment-radio-group__options',
props.orientation === 'horizontal' &&
'rudiment-radio-group__options--horizontal',
)}
>
{props.children}
</div>
{props.description && !props.errorMessage && (
<p {...descriptionProps} className="rudiment-radio-group__description">
{props.description}
</p>
)}
{props.errorMessage && (
<p {...errorMessageProps} className="rudiment-radio-group__error">
{props.errorMessage}
</p>
)}
</div>
)
}
The individual Radio component uses useRadio and receives the group state via React context (set up internally by React Aria). The implementation follows the same hidden-input-plus-visual-indicator pattern as Checkbox.
Both CheckboxGroup and RadioGroup include a description prop and spread descriptionProps onto a visible element, following the same pattern established in Chapter 6's Input. The description is hidden when an error is active, so screen readers don't announce both simultaneously.
Switch
Switch is semantically distinct from Checkbox. A checkbox is a selection control ("agree to terms"). A switch is a toggle control ("enable notifications"). The distinction matters for screen reader users: role="switch" communicates a different interaction model than role="checkbox".
// src/components/Switch/Switch.tsx
import { forwardRef } from 'react'
import { useSwitch, VisuallyHidden } from 'react-aria'
import { useObjectRef } from '@react-aria/utils'
import { useToggleState } from 'react-stately'
import { cn } from '@/utils/cn'
export interface SwitchProps {
children: React.ReactNode
isSelected?: boolean
defaultSelected?: boolean
onChange?: (isSelected: boolean) => void
isDisabled?: boolean
className?: string
}
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
function Switch(props, forwardedRef) {
const ref = useObjectRef(forwardedRef)
const state = useToggleState(props)
const { inputProps } = useSwitch(props, state, ref)
return (
<label
className={cn(
'rudiment-switch',
props.isDisabled && 'rudiment-switch--disabled',
props.className,
)}
>
<VisuallyHidden>
<input {...inputProps} ref={ref} />
</VisuallyHidden>
<span
className={cn(
'rudiment-switch__track',
state.isSelected && 'rudiment-switch__track--on',
)}
aria-hidden="true"
>
<span className="rudiment-switch__thumb" />
</span>
<span className="rudiment-switch__label">{props.children}</span>
</label>
)
},
)
VisuallyHidden is a React Aria utility that hides the native input from sight while keeping it accessible to screen readers. The visible switch track and thumb are styled via CSS to animate between on and off positions.
What you have now
All form components are built: Button, IconButton, Input, Select, Checkbox, CheckboxGroup, RadioGroup, Switch. Each uses React Aria for behavior and accessibility, with your token-driven CSS handling the visual layer. You can compose a complete form using these components inside a Stack:
// Example roles data for the Select component
const roles = [
{ id: 'design', name: 'Design' },
{ id: 'engineering', name: 'Engineering' },
{ id: 'product', name: 'Product' },
]
;<Stack space="1.5rem" style={{ maxWidth: '24rem' }}>
<Input label="Name" isRequired />
<Input label="Email" type="email" isRequired />
<Select label="Role" placeholder="Choose a role" items={roles}>
{(role) => <span key={role.id}>{role.name}</span>}
</Select>
<CheckboxGroup label="Interests">
<Checkbox value="design">Design</Checkbox>
<Checkbox value="engineering">Engineering</Checkbox>
<Checkbox value="product">Product</Checkbox>
</CheckboxGroup>
<Switch>Receive email updates</Switch>
<Button variant="primary">Submit</Button>
</Stack>
Chapters
- Introduction: Build your own component library
- The architecture before the code
- Project setup
- Design tokens from scratch
- Your first layout primitive: Stack
- The rest of the layout system
- Typography: Heading, Text, and Prose
- Accessible components with React Aria
- Form components
- Overlays and feedback
- Storybook as your documentation layer
- Testing accessible components
- Packaging and distribution
- What comes next