Radio

Radio is the standard single-select control — a recessed thumb with a filled-gradient selected state that takes the theme accent by default. Like Checkbox, it renders a real hidden input for form submission and flips to an ARIA-only mode when the surrounding context already provides a label.

<Radio checked={pick === 'a'} onChange={() => setPick('a')} />
<Radio
  checked={pick === 'b'}
  onChange={() => setPick('b')}
  color="green"
/>
<Radio
  checked={pick === 'c'}
  onChange={() => setPick('c')}
  color="purple"
  size="md"
/>
<Radio checked disabled />
<Radio checked readOnly color="red" />
<Radio disabled />

Usage

import { Radio } from '@cladd-ui/react';
 
const [view, setView] = useState('list');
 
<Radio
  name="view"
  checked={view === 'list'}
  onChange={() => setView('list')}
/>
<Radio
  name="view"
  checked={view === 'board'}
  onChange={() => setView('board')}
/>

A radio set is just a group of Radios sharing a single source-of-truth value — each radio compares its own option id against that value and fires onChange with no argument-driven toggle (you set the value directly). Wrap the set in a <label> (or use as="label", the default) and clicking anywhere inside the label activates the hidden input. Pass as="span" / as="div" when the radio already lives inside a label-shaped parent (a ListButton with as="label", for instance).

Examples

Sizes

size accepts sm (default) and md. Unlike Button, Chip, and Spinner — which run the full 2xs → 2xl scale — Radio ships only the two sizes that match its sibling Checkbox. sm (20px) is the inline default; md (24px) is the right call when the radio is the focal point of its row, or when it's nested inside a same-sized md button or list row (see Inside a button or list row below).

<Radio size="md" checked />
<Radio size="md" />

Inside a button or list row

Radio's two sizes are nested sizes — calibrated to fit inside a same-sized Button or ListButton without crowding the content row. Pass the same size token to the wrapping control and the radio, and the proportions land. Use as="span" on the radio so it doesn't render a nested <label> inside the parent label.

The same name on every radio in the set is a hint for assistive tech and form serialization — it doesn't enable native exclusive selection (the component renders a hidden checkbox under the hood; the parent enforces single-select by holding one shared value).

<Button as="label" size="md" variant="transparent">
  <Radio
    as="span"
    size="md"
    name="theme-nested"
    checked={theme === 'system'}
    onChange={() => setTheme('system')}
    color="brand"
  />
  Match system
</Button>
<Button as="label" size="md" variant="transparent">
  <Radio
    as="span"
    size="md"
    name="theme-nested"
    checked={theme === 'light'}
    onChange={() => setTheme('light')}
  />
  Always light
</Button>
<Button as="label" size="md" variant="transparent">
  <Radio
    as="span"
    size="md"
    name="theme-nested"
    checked={theme === 'dark'}
    onChange={() => setTheme('dark')}
  />
  Always dark
</Button>

Colors

color accepts any of the cladd accent tokens — the accent drives the selected-state fill. When unset, the radio inherits the theme accent from CladdProvider, so a brand-coloured radio set doesn't usually need a color prop. Pass one when the selection signals severity (red for a destructive option) or sits alongside a non-default control that needs to read as a single unit.

<Radio checked color="brand" size="md" />
<Radio checked color="brand" />

In a list

The canonical single-select pattern: a List of ListButton as="label" rows, each with a Radio in the icon slot. The ListButton already provides the hover overlay, focus ring, and click target — the radio is just the indicator. as="label" on the row turns the whole row into a label for the nested radio's hidden input, so clicking anywhere in the row selects it. Use as="span" on the radio itself to avoid a nested <label>.

Default view
<Surface outline className="w-64 rounded-3xl">
  <List>
    <ListTitle>Default view</ListTitle>
    {VIEWS.map((v) => (
      <ListButton
        key={v.id}
        as="label"
        icon={
          <Radio
            as="span"
            name="default-view"
            checked={view === v.id}
            onChange={() => setView(v.id)}
          />
        }
      >
        {v.label}
      </ListButton>
    ))}
    <ListSeparator />
    <ListButton onClick={() => setView('board')}>
      Reset to default
    </ListButton>
  </List>
</Surface>

Form group

When the radios are the form — a settings panel choosing between mutually exclusive options, a pricing tier picker, a survey question — wrap each option in a Surface as="label" and let the surface variant flip on selection. The radio sits in the corner as the indicator; the surface itself is the click target, the affordance, and the visual frame. Share a single name across the set so the form submission carries the picked value.

<form
  className="flex w-96 flex-col gap-2"
  onSubmit={(e) => e.preventDefault()}
>
  {PLANS.map((p) => (
    <Surface
      key={p.id}
      as="label"
      outline
      hoverable
      clickable
      variant={plan === p.id ? 'gradient' : 'transparent'}
      className="cursor-pointer rounded-2xl"
      contentClassName="flex items-start gap-4 p-4"
    >
      <Radio
        as="span"
        name="plan"
        value={p.id}
        checked={plan === p.id}
        onChange={() => setPlan(p.id)}
        color="brand"
        className="mt-0.5"
      />
      <div className="flex-1">
        <div className="flex items-center justify-between gap-2">
          <span className="font-medium text-cladd-fg">{p.label}</span>
          <span className="font-mono text-cladd-fg-soft">{p.price}</span>
        </div>
        <p className="mt-1 text-cladd-fg-soft">{p.description}</p>
      </div>
    </Surface>
  ))}
</form>

States

disabled dims the radio and blocks all interaction — read as "not available right now". readOnly blocks toggling without the dim treatment — read as "locked, but the value is real" (e.g. an enforced organisation policy the user can see but not change). required forwards to the native <input> so the browser's form validation will catch an empty submission.

<label className="flex items-center gap-2">
  <Radio as="span" checked disabled />
  <span className="text-cladd-fg-soft">Disabled, selected</span>
</label>
<label className="flex items-center gap-2">
  <Radio as="span" disabled />
  <span className="text-cladd-fg-soft">Disabled, unselected</span>
</label>
<label className="flex items-center gap-2">
  <Radio as="span" checked readOnly color="red" />
  <span className="text-cladd-fg-soft">Read-only (locked)</span>
</label>
<label className="flex items-center gap-2">
  <Radio as="span" required />
  <span className="text-cladd-fg-soft">Required</span>
</label>

Playground

size, color, and the boolean state toggles compose freely. The sm / md pair covers most cases; reach for md when the radio is the focal point of a row or sits inside a md button or list row.

<Radio
  checked
  onChange={setChecked}
  size="md"
  color="brand"
  disabled={false}
  readOnly={false}
/>

API Reference

C extends ElementType = 'label'
as: ElementType'label'Polymorphic root element. Defaults to 'label' so a wrapping <label> activates the hidden input on click. Use a non-label container when the radio lives inside an existing label — see hoverable/focusable for how this changes interactivity.
checked: booleanfalseControlled checked state. Default false.
className: stringExtra classes for the outer label/element.
color: ColorAccent color for the checked state. Default: theme accent.
disabled: booleanVisually dim the radio and disable interaction.
focusable: booleanAuto-computed when omitted: true if as === 'label' OR input is true.

Drives whether the focus ring (FocusableLayer) is rendered.
hoverable: booleanAuto-computed when omitted: true if as === 'label', otherwise false.

Override explicitly for custom containers that should still show hover affordances.
input: booleanWhen true (default), renders a hidden native input for form submission and accessibility.

When false, the component falls back to ARIA roles (role="radio", aria-checked, keyboard Space/Enter toggling) and onChange fires from the click handler.
inputId: stringid for the hidden <input>. Used to wire an external <label htmlFor> to this radio.
name: stringNative name - used to group radios in the same set.
onChange: (checked: boolean, event?: ChangeEvent<HTMLInputElement>) => voidFires when the user toggles the radio. First arg is the new checked state, second is the raw event.
onClick: (e: MouseEvent) => voidFires on click of the root element. Runs before the internal toggle handler.
onPointerDown: (e: PointerEvent) => voidFires on pointerdown of the root element.
readOnly: booleanBlock toggling without the disabled visual treatment.
required: booleanNative required - forwarded to the hidden <input> for form validation.
size: RadioSize'sm'Radio size token. Default 'sm'.
value: stringNative value - submitted with the form when checked.