RadioGroup
A radio group picks exactly one option from a set. The group is a single tab stop. The arrow keys move between options and select as they go, which is how native radios behave and what the ARIA pattern expects.
A radio option does not have to be a small circle. Each RadioGroupOption is a
container with its own label and description slots, so cards and tiles work
naturally.
import {
RadioGroup,
RadioGroupLabel,
RadioGroupDescription,
RadioGroupOption,
} from 'terracotta/radio-group';
import { SelectStateChild, useSelectState } from 'terracotta/states';Anatomy
<RadioGroup> {/* role="radiogroup", owns the value */}
<RadioGroupLabel/> {/* names the group */}
<RadioGroupDescription/> {/* describes the group */}
<RadioGroupOption> {/* role="radio" */}
<RadioGroupLabel/> {/* names this option */}
<RadioGroupDescription/>{/* describes this option */}
</RadioGroupOption>
</RadioGroup>RadioGroupLabel and RadioGroupDescription bind to whichever is closest: the
group at the top level, or the option when nested inside one.
Examples
Cards
Classic radio dots
The dot is CSS driven by tc-checked:
Controlled
const [speed, setSpeed] = createSignal('standard');
<RadioGroup<string>
class="radiogroup"
value={speed()}
onChange={value => value && setSpeed(value)}
>
…
</RadioGroup>onChange receives V | undefined. The undefined case only comes up with
toggleable, which lets the user clear the selection.
Object values
interface Plan { id: string; name: string; price: string }
<RadioGroup<Plan>
class="radiogroup"
defaultValue={plans[0]}
by={(a, b) => a.id === b.id}
>
<RadioGroupLabel class="radiogroup-label">Plan</RadioGroupLabel>
<For each={plans}>
{plan => (
<RadioGroupOption class="radio-card" value={plan}>
<RadioGroupLabel class="radio-card-title">{plan.name}</RadioGroupLabel>
<RadioGroupDescription class="radio-card-hint">{plan.price}</RadioGroupDescription>
</RadioGroupOption>
)}
</For>
</RadioGroup>Group-level description
Placed outside any option, the description belongs to the group. The group's
aria-describedby points at it:
<RadioGroup<string> class="radiogroup" defaultValue="standard">
<RadioGroupLabel class="radiogroup-label">Delivery speed</RadioGroupLabel>
<RadioGroupDescription class="radiogroup-hint">
Estimated dates assume orders placed before 4pm.
</RadioGroupDescription>
…
</RadioGroup>Disabled options
<RadioGroupOption class="radio-card" value="overnight" disabled>
<RadioGroupLabel class="radio-card-title">Overnight</RadioGroupLabel>
<RadioGroupDescription class="radio-card-hint">
Not available for your address
</RadioGroupDescription>
</RadioGroupOption>The arrow keys skip disabled options.
Horizontal layout
There is no horizontal prop. Arrow keys on both axes always work, so the
layout is entirely yours:
.radiogroup-inline {
flex-direction: row;
flex-wrap: wrap;
}Reading the selection
<RadioGroup<string> class="radiogroup" defaultValue="standard">
{state => (
<>
<RadioGroupLabel class="radiogroup-label">Delivery speed</RadioGroupLabel>
{/* options */}
<p class="hint">{state.hasSelected() ? 'Ready to continue' : 'Choose one'}</p>
</>
)}
</RadioGroup>From a nested component, useSelectState() reads the group and
useSelectOptionState() reads the option.
In a form
RadioGroup renders no <input>. Add a hidden one if the value must be
submitted natively:
<RadioGroup<string> class="radiogroup" value={speed()} onChange={v => v && setSpeed(v)}>
…
<input type="hidden" name="speed" value={speed()} />
</RadioGroup>State attributes
| Element | Attribute | Present when |
|---|---|---|
RadioGroup |
tc-radio-group |
Always |
RadioGroup |
tc-has-selected |
An option is selected |
RadioGroup |
tc-has-active |
An option holds keyboard focus |
RadioGroup |
tc-disabled |
The group is disabled |
RadioGroupOption |
tc-radio-group-option, tc-button, tc-owner |
Always |
RadioGroupOption |
tc-checked |
This option is selected |
RadioGroupOption |
tc-active |
This option holds keyboard focus |
RadioGroupOption |
tc-disabled |
This option or the group is disabled |
RadioGroupLabel |
tc-radio-group-label |
Always |
RadioGroupDescription |
tc-radio-group-description |
Always |
The option uses tc-checked, not tc-selected. It is a radio, so it
follows the checkbox family of attributes and carries aria-checked too. The
label and description carry no state attributes, so style them through the
option.
Styling
[tc-radio-group-option][tc-checked] { border-color: #2563eb; background: #eff6ff; }
[tc-radio-group-option][tc-active] { box-shadow: 0 0 0 3px rgb(37 99 235 / 0.2); }
[tc-radio-group-option][tc-disabled] { opacity: 0.5; cursor: not-allowed; }
/* Reach the nested label from the option's state */
[tc-radio-group-option][tc-checked] [tc-radio-group-label] { color: #1d4ed8; }
/* Prompt the user while nothing is chosen */
[tc-radio-group]:not([tc-has-selected]) .radiogroup-prompt { display: block; }
[tc-radio-group][tc-has-selected] .radiogroup-prompt { display: none; }aria-checked mirrors tc-checked, so [aria-checked="true"] works just as
well.
Reading the state in code
On the group, from the select state:
| Member | Type | Description |
|---|---|---|
isSelected(value) |
(V) => boolean |
Whether that value is selected. |
select(value) |
(V) => void |
Selects it. |
hasSelected() |
boolean |
Whether anything is selected. |
isActive(value) / hasActive() |
Keyboard focus position. | |
disabled() |
boolean |
Whether the group is disabled. |
Inside an option, from the select option state:
| Member | Type | Description |
|---|---|---|
isSelected() |
boolean |
Whether this option is selected. |
isActive() |
boolean |
Whether this option holds keyboard focus. |
select() |
() => void |
Selects this option. |
disabled() |
boolean |
Whether this option is disabled. |
Keyboard
| Key | Action |
|---|---|
| ↓ / → | Next option, wrapping around |
| ↑ / ← | Previous option, wrapping around |
| Tab | Enters or leaves the group. Only one option is in the tab order. |
The option in the tab order is the checked one. When nothing is checked, or the checked option is disabled, it is the first enabled option.
The arrow keys check the option they move to. Focus alone does not check an option, so Tab into a group with nothing checked leaves it unchecked. Disabled options are skipped.
API
<RadioGroup>
Owns a single-selection select state and renders a
<div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
defaultValue |
V |
none | Initially selected value, uncontrolled. Mutually exclusive with value. |
value |
V |
none | Currently selected value, controlled. Mutually exclusive with defaultValue. |
onChange |
(value?: V) => void |
none | Called with the new selection. |
toggleable |
boolean |
false |
Allow clearing the selection by choosing the selected option again. |
disabled |
boolean |
false |
Disables the whole group. |
by |
(a: V, b: V) => boolean |
reference equality | Compares values. Needed for object values. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: SelectStateProperties<V>) => JSX.Element |
none | Label, description and options, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="radiogroup". aria-labelledby and
aria-describedby are set only while a RadioGroupLabel or
RadioGroupDescription is rendered directly in the group. Your own values for
them take precedence.
<RadioGroupOption>
One choice. A Button with role="radio". It renders a <div>
by default, so it can hold a label and a description.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
value |
V |
required | The value this option represents. |
disabled |
boolean |
false |
Disables this option and removes it from navigation. It is also disabled when the group is. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: SelectOptionStateProperties) => JSX.Element |
none | The option's contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="radio", aria-checked and tabindex.
aria-labelledby and aria-describedby are set only while the option renders
its own RadioGroupLabel or RadioGroupDescription. Your own values for them
take precedence. The tabindex is 0 on one option and -1 on the rest,
which is what makes the group a single tab stop.
<RadioGroupLabel>
Names the nearest group or option. Renders a <label> by default. It takes no
ref and carries no state attributes of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'label' |
Element or component to render as. |
children |
JSX.Element |
none | Label text. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<RadioGroupDescription>
Describes the nearest group or option. Renders a <div> by default. Does not
take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
children |
JSX.Element |
none | Description text. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
RadioGroupOption throws outside a <RadioGroup>. The label and description
throw outside a <RadioGroup> or <RadioGroupOption>.