Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 8

Form components

This chapter builds Select, Checkbox, CheckboxGroup, RadioGroup, and Switch. Each one follows the React Aria hook pattern from the previous chapter.

Select Medium Small Medium Large Checkbox Email me updates RadioGroup Monthly Yearly Switch Dark mode
Select, Checkbox, RadioGroup, and Switch: four controls, one React Aria pattern.

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: four hooks behind one control, and the option it reports as chosen.

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)
// Popover, ListBox, Option, and ChevronIcon are helpers internal to Select,
// defined below. The focus here is 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 RudiSelectProps<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
  /** Submitted with the form. Without it the value never reaches FormData. */
  name?: string
  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="rudi-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="rudi-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="rudi-select__option"
      data-focused={isFocused}
      aria-selected={isSelected}
    >
      {item.rendered}
    </li>
  )
}

export function RudiSelect<T extends object>(props: RudiSelectProps<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('rudi-select', props.className)}>
      <label {...labelProps} className="rudi-select__label">
        {props.label}
      </label>
      <HiddenSelect
        state={state}
        triggerRef={triggerRef}
        label={props.label}
        name={props.name}
      />
      <button
        {...buttonProps}
        ref={triggerRef}
        className={cn(
          'rudi-select__trigger',
          props.errorMessage && 'rudi-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="rudi-select__description">
          {props.description}
        </p>
      )}
      {props.errorMessage && (
        <p {...errorMessageProps} className="rudi-select__error">
          {props.errorMessage}
        </p>
      )}
    </div>
  )
}

The HiddenSelect component from React Aria renders a hidden native <select> element for form submission compatibility. It only works if you give it a name: the browser skips any form control without one, so a Select without name submits nothing at all, silently. 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 difficult. React Aria handles the behavioral complexity so your component code is mostly rendering and styling.

Here’s the finished Select rendered from the companion repository’s Storybook. Open the popover to exercise the keyboard navigation, typeahead, and focus behavior described above.

Select in Storybook — open in Storybook

The option element

Select takes an items array and a render function, and that render function has a constraint that is easy to get wrong. It must return a React Stately collection element, not arbitrary JSX.

React Stately builds its collection by walking the returned elements and reading a static getCollectionNode property off each component type. It never renders them in the usual sense. A <span> has no such property, so returning one throws inside useSelectState before anything reaches the DOM, with a stack trace that points into React Stately rather than at your code.

The element that does carry it is Item from react-stately. Rather than leaving consumers to import from a transitive dependency, re-export it under your own name. Create src/components/Select/Option.ts:

// `Item` is a react-stately *collection* component (identified by a static
// `getCollectionNode`), so it must be re-exported rather than wrapped — a
// function wrapper would break the collection builder that RudiSelect relies on.
export { Item as RudiOption } from 'react-stately'
export type { ItemProps as RudiOptionProps } from 'react-stately'

The comment is worth keeping in your own copy. The obvious refactor here is to wrap Item in a component that adds a class name or default props, and that refactor silently breaks the collection: the wrapper is a new component type without the static property, so React Stately stops recognizing it as an option. Re-exporting is the simplest thing that works; a wrapper would also have to copy getCollectionNode from Item.

Add both to src/index.ts alongside Select. Without RudiOption in the public API, the only way to use RudiSelect is to import Item from react-stately directly, which leaks a dependency the consumer never installed.

Checkbox and CheckboxGroup

Checkbox: on, off, and the mixed state a partly checked group reports.

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'
import './checkbox.css'

export interface RudiCheckboxProps {
  children: React.ReactNode
  isSelected?: boolean
  defaultSelected?: boolean
  isIndeterminate?: boolean
  onChange?: (isSelected: boolean) => void
  isDisabled?: boolean
  value?: string
  className?: string
}

export const RudiCheckbox = forwardRef<HTMLInputElement, RudiCheckboxProps>(
  function RudiCheckbox(props, forwardedRef) {
    const ref = useObjectRef(forwardedRef)
    const state = useToggleState(props)
    const { inputProps } = useCheckbox(props, state, ref)

    return (
      <label
        className={cn(
          'rudi-checkbox',
          props.isDisabled && 'rudi-checkbox--disabled',
          props.className,
        )}
      >
        <input {...inputProps} ref={ref} className="rudi-checkbox__input" />
        <span
          className={cn(
            'rudi-checkbox__control',
            state.isSelected && 'rudi-checkbox__control--checked',
            props.isIndeterminate &&
              'rudi-checkbox__control--indeterminate',
          )}
          aria-hidden="true"
        />
        <span className="rudi-checkbox__label">{props.children}</span>
      </label>
    )
  },
)

The hidden native <input> provides the actual checkbox behavior. The rudi-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 was installed in chapter 7.

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'
import './checkbox.css'

export interface RudiCheckboxGroupProps {
  label: string
  description?: string
  value?: string[]
  defaultValue?: string[]
  onChange?: (value: string[]) => void
  isDisabled?: boolean
  errorMessage?: string
  children: React.ReactNode
  className?: string
}

export function RudiCheckboxGroup(props: RudiCheckboxGroupProps) {
  const state = useCheckboxGroupState(props)
  const { groupProps, labelProps, descriptionProps, errorMessageProps } =
    useCheckboxGroup(props, state)

  return (
    <div
      {...groupProps}
      className={cn('rudi-checkbox-group', props.className)}
    >
      <span {...labelProps} className="rudi-checkbox-group__label">
        {props.label}
      </span>
      {props.children}
      {props.description && !props.errorMessage && (
        <p
          {...descriptionProps}
          className="rudi-checkbox-group__description"
        >
          {props.description}
        </p>
      )}
      {props.errorMessage && (
        <p {...errorMessageProps} className="rudi-checkbox-group__error">
          {props.errorMessage}
        </p>
      )}
    </div>
  )
}

Here’s the finished Checkbox rendered from the companion repository’s Storybook, including the indeterminate state and grouped variants.

Checkbox in Storybook — open in Storybook

RadioGroup

RadioGroup: arrow keys move the selection inside the group, Tab steps past it.

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 { createContext } from 'react'
import { useRadioGroup } from 'react-aria'
import { useRadioGroupState, type RadioGroupState } from 'react-stately'
import { cn } from '@/utils/cn'
import './radio-group.css'

export const RadioGroupContext = createContext<RadioGroupState | null>(null)

export interface RudiRadioGroupProps {
  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 RudiRadioGroup(props: RudiRadioGroupProps) {
  const state = useRadioGroupState(props)
  const { radioGroupProps, labelProps, descriptionProps, errorMessageProps } =
    useRadioGroup(props, state)

  return (
    <RadioGroupContext.Provider value={state}>
      <div
        {...radioGroupProps}
        className={cn('rudi-radio-group', props.className)}
      >
        <span {...labelProps} className="rudi-radio-group__label">
          {props.label}
        </span>
        <div
          className={cn(
            'rudi-radio-group__options',
            props.orientation === 'horizontal' &&
              'rudi-radio-group__options--horizontal',
          )}
        >
          {props.children}
        </div>
        {props.description && !props.errorMessage && (
          <p {...descriptionProps} className="rudi-radio-group__description">
            {props.description}
          </p>
        )}
        {props.errorMessage && (
          <p {...errorMessageProps} className="rudi-radio-group__error">
            {props.errorMessage}
          </p>
        )}
      </div>
    </RadioGroupContext.Provider>
  )
}

The context is yours to create and provide. This is the one place in the chapter where React Aria does less than you might expect: useRadio requires the group’s RadioGroupState as an argument, and it has no opinion about how the individual radio obtains it. Every implementation ends up building this same bridge, so it is worth being explicit that the createContext call and the Provider wrapper are load-bearing. Drop either one and each radio reads null, useRadio throws, and nothing renders.

The individual Radio component reads the state from RadioGroupContext, passes it to useRadio along with its own props, and follows the same hidden-input-plus-visual-indicator pattern as Checkbox: a visually hidden native <input type="radio"> carrying the semantics and the focus target, and a styled <span> next to it that draws the control.

Both CheckboxGroup and RadioGroup include a description prop and spread descriptionProps onto a visible element, following the same pattern established in Chapter 7’s Input. The description is hidden when an error is active, so screen readers don’t announce both simultaneously.

Here’s the finished RadioGroup rendered from the companion repository’s Storybook. Use the arrow keys once focus is inside the group to see the keyboard behavior described above.

RadioGroup in Storybook — open in Storybook

Switch

Switch: the same two states as a checkbox, announced with a different role.

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'
import './switch.css'

export interface RudiSwitchProps {
  children: React.ReactNode
  isSelected?: boolean
  defaultSelected?: boolean
  onChange?: (isSelected: boolean) => void
  isDisabled?: boolean
  className?: string
}

export const RudiSwitch = forwardRef<HTMLInputElement, RudiSwitchProps>(
  function RudiSwitch(props, forwardedRef) {
    const ref = useObjectRef(forwardedRef)
    const state = useToggleState(props)
    const { inputProps } = useSwitch(props, state, ref)

    return (
      <label
        className={cn(
          'rudi-switch',
          props.isDisabled && 'rudi-switch--disabled',
          props.className,
        )}
      >
        <VisuallyHidden>
          <input {...inputProps} ref={ref} />
        </VisuallyHidden>
        <span
          className={cn(
            'rudi-switch__track',
            state.isSelected && 'rudi-switch__track--on',
          )}
          aria-hidden="true"
        >
          <span className="rudi-switch__thumb" />
        </span>
        <span className="rudi-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.

Here’s the finished Switch rendered from the companion repository’s Storybook. Toggle it to see the track-and-thumb animation described above.

Switch in Storybook — open in Storybook

What you have now

The core 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:

import { RudiSelect } from '@/components/Select/Select'
import { RudiOption } from '@/components/Select/Option'

const roles = [
  { id: 'design', label: 'Design' },
  { id: 'engineering', label: 'Engineering' },
  { id: 'product', label: 'Product' },
]

;<RudiStack space="1.5rem" style={{ maxWidth: '24rem' }}>
  <RudiInput label="Name" isRequired />
  <RudiInput label="Email" type="email" isRequired />
  <RudiSelect label="Role" placeholder="Choose a role" items={roles}>
    {(role) => <RudiOption key={role.id}>{role.label}</RudiOption>}
  </RudiSelect>
  <RudiCheckboxGroup label="Interests">
    <RudiCheckbox value="design">Design</RudiCheckbox>
    <RudiCheckbox value="engineering">Engineering</RudiCheckbox>
    <RudiCheckbox value="product">Product</RudiCheckbox>
  </RudiCheckboxGroup>
  <RudiSwitch>Receive email updates</RudiSwitch>
  <RudiButton variant="primary">Submit</RudiButton>
</RudiStack>

The Select render function returns RudiOption, for the collection reason covered earlier. This is the detail that catches people, because returning a <span> looks reasonable and fails at runtime rather than at compile time.

Two form components are still missing at this point. RudiTextarea follows Input almost exactly, adding rows and a resize prop. RudiRadio is the companion to RadioGroup described above. Chapter 14 covers both alongside the rest of the component set.

Every form element now behaves the same way: a React Aria hook supplies the accessibility layer, your CSS supplies the visuals, and layout primitives from chapters 4 and 5 arrange them on the page without any form-specific layout code. The next chapter steps beyond forms into the overlays and feedback surfaces that sit on top of this interactive layer.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.