Selection
Radio button
Lets people select one option from a set.
- COMPONENT_SPEC §23
- CSS · React
- Open in the playground
Examples
Radio group
Native radios: one tab stop for the group, the arrow keys select. No JavaScript needed.
import { Radio, RadioGroup } from '@kyralabs/m3exp-web/react';
export default function Group() {
return (
<RadioGroup aria-label="Notifications" defaultValue="calls" name="notifications">
<Radio value="all">All notifications</Radio>
<Radio value="calls">Calls only</Radio>
<Radio value="none">None</Radio>
<Radio value="custom" isDisabled>
Custom (unavailable)
</Radio>
</RadioGroup>
);
}
<div class="m3e-radio-group" role="radiogroup" aria-label="Notifications">
<label class="m3e-radio-button">
<input class="m3e-radio-button__input" type="radio" name="notifications" value="all" />
<span class="m3e-radio-button__icon" aria-hidden="true"></span>
<span class="m3e-radio-button__state" aria-hidden="true"></span>
<span class="m3e-radio-button__label">All notifications</span>
</label>
<label class="m3e-radio-button">
<input
class="m3e-radio-button__input"
type="radio"
name="notifications"
value="calls"
checked
/>
<span class="m3e-radio-button__icon" aria-hidden="true"></span>
<span class="m3e-radio-button__state" aria-hidden="true"></span>
<span class="m3e-radio-button__label">Calls only</span>
</label>
<label class="m3e-radio-button">
<input class="m3e-radio-button__input" type="radio" name="notifications" value="none" />
<span class="m3e-radio-button__icon" aria-hidden="true"></span>
<span class="m3e-radio-button__state" aria-hidden="true"></span>
<span class="m3e-radio-button__label">None</span>
</label>
<label class="m3e-radio-button">
<input
class="m3e-radio-button__input"
type="radio"
name="notifications"
value="custom"
disabled
/>
<span class="m3e-radio-button__icon" aria-hidden="true"></span>
<span class="m3e-radio-button__state" aria-hidden="true"></span>
<span class="m3e-radio-button__label">Custom (unavailable)</span>
</label>
</div>
Run the HTML version (CSS classes only).
API
React
<RadioGroup>
Radio group (COMPONENT_SPEC §23): React Aria useRadioGroup / react-stately useRadioGroupState on the m3e-css .m3e-radio-group contract (role="radiogroup", Compose's selectableGroup). Arrow keys move the selection and wrap; Tab enters at the selected radio. Name it with aria-label or aria-labelledby. The ref is the group element.
| Prop | Type | Default | Description |
|---|---|---|---|
children
|
ReactNode
|
The <Radio>s.
|
|
value
|
string | null
|
Controlled selected value (null: none).
|
|
orientation
|
'vertical' | 'horizontal'
|
Lays the radios out in a column (default, Compose's RadioGroupSample) or a row. | |
name
|
string
|
The radios' form name (generated when omitted). | |
className
|
string
|
||
defaultValue
|
string | null
|
||
isDisabled
|
boolean
|
||
isReadOnly
|
boolean
|
||
isRequired
|
boolean
|
||
form
|
string
|
||
id
|
string
|
||
aria-label
|
string
|
||
aria-labelledby
|
string
|
||
aria-describedby
|
string
|
||
onChange
|
(value: string) => void
|
Called with the newly selected value. | |
onFocus
|
(e: React.FocusEvent) => void
|
||
onBlur
|
(e: React.FocusEvent) => void
|
<Radio>
Radio button (COMPONENT_SPEC §23, Compose RadioButton): React Aria useRadio on the m3e-css .m3e-radio-button contract, inside a <RadioGroup>. The ref is the native input. The ring and dot animate in CSS (radio-button.css); this drives the ripple.
| Prop | Type | Default | Description |
|---|---|---|---|
children
|
ReactNode
|
The label (Compose's RadioGroupSample row: the whole label selects). | |
value
required
|
string
|
The value the group reports when this radio is selected. | |
className
|
string
|
||
isDisabled
|
boolean
|
||
id
|
string
|
||
autoFocus
|
boolean
|
||
aria-label
|
string
|
||
aria-labelledby
|
string
|
||
aria-describedby
|
string
|
||
onPressStart
|
(e: PressEvent) => void
|
||
onPressEnd
|
(e: PressEvent) => void
|
||
onFocus
|
(e: React.FocusEvent) => void
|
||
onBlur
|
(e: React.FocusEvent) => void
|
CSS
radio-button.css
@import '@kyralabs/m3e-css/radio-button.css';
Radio button (COMPONENT_SPEC §23): native radios, selected / unselected, disabled. No JavaScript needed: radios sharing a name select one at a time, and arrow keys move the selection (wrapping), Tab enters at the selected one, Space selects.
<div class="m3e-radio-group" role="radiogroup" aria-label="Notifications">
<label class="m3e-radio-button">
<input class="m3e-radio-button__input" type="radio" name="n" value="calls" checked />
<span class="m3e-radio-button__icon" aria-hidden="true"></span>
<span class="m3e-radio-button__state" aria-hidden="true"></span>
<span class="m3e-radio-button__label">Calls</span>
</label>
…
</div>
The group may be a <fieldset class="m3e-radio-group"> with a <legend> instead. The label text is optional (then name the input with aria-label). aria-orientation="horizontal" on a radiogroup (or the .m3e-radio-group--horizontal modifier, e.g. on a fieldset, which does not take aria-orientation) lays the radios out in a row.
25 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-minimum-interactive-component-size
|
48px |
--m3e-comp-radio-button-color-duration
|
var(--m3e-sys-motion-spring-default-effects-duration) |
--m3e-comp-radio-button-color-easing
|
var(--m3e-sys-motion-spring-default-effects-easing) |
--m3e-comp-radio-button-color-selected
|
var(--m3e-sys-color-primary) |
--m3e-comp-radio-button-color-selected-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-radio-button-color-unselected
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-radio-button-color-unselected-disabled
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-radio-button-dot-duration
|
var(--m3e-sys-motion-spring-fast-spatial-duration) |
--m3e-comp-radio-button-dot-easing
|
var(--m3e-sys-motion-spring-fast-spatial-easing) |
--m3e-comp-radio-button-dot-size
|
12px |
--m3e-comp-radio-button-group-row-height
|
56px |
--m3e-comp-radio-button-icon-size
|
20px |
--m3e-comp-radio-button-label-space
|
16px |
--m3e-comp-radio-button-label-text-font
|
var(--m3e-sys-typescale-body-large-font) |
--m3e-comp-radio-button-label-text-line-height
|
var(--m3e-sys-typescale-body-large-line-height) |
--m3e-comp-radio-button-label-text-size
|
var(--m3e-sys-typescale-body-large-size) |
--m3e-comp-radio-button-label-text-tracking
|
var(--m3e-sys-typescale-body-large-tracking) |
--m3e-comp-radio-button-label-text-weight
|
var(--m3e-sys-typescale-body-large-weight) |
--m3e-comp-radio-button-opacity-selected-disabled
|
0.38 |
--m3e-comp-radio-button-opacity-unselected-disabled
|
0.38 |
--m3e-comp-radio-button-padding
|
2px |
--m3e-comp-radio-button-state-layer-color
|
currentcolor |
--m3e-comp-radio-button-state-layer-shape
|
var(--m3e-sys-shape-corner-full) |
--m3e-comp-radio-button-state-layer-size
|
40px |
--m3e-comp-radio-button-stroke-width
|
2px |
Accessibility
native <input type="radio">s sharing a name in a role="radiogroup" (or a <fieldset>), so arrow keys move the selection (wrapping in Chromium and Firefox; WebKit stops at the ends, a UA difference; skipping disabled radios), Tab enters at the selected radio and Space selects, with no JavaScript (m3.material.io; Compose has no arrow-key handling). Enter does not select. React: React Aria's roving tab stop and arrow keys, the same behavior.
Every interactive part has a target of at least 48 × 48 px, a visible focus ring for
keyboard focus only, and works under forced-colors and
prefers-reduced-motion.
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §23 and the m3e-css sources.
RadioButtonTokens.kt@19991f2c plus the RadioButton.kt / RadioButtonDefaults values Compose renders (components/radio-button-defaults.css; SOURCES register #40): a 20dp ring (IconSize, 2dp RadioStrokeWidth) inside 2dp padding (RadioButtonPadding), centred in the 48dp minimum interactive size; the dot radius 6dp when selected (RadioButtonDotSize / 2), drawn at radius − stroke / 2 (a 10dp dot), nothing while the radius is ≤ 0; 40dp state layer circle (StateLayerSize, unbounded ripple of radius 20dp from the centre, CircleShape focus ring). Colors (defaultRadioButtonColors), ring and dot alike: selected primary, unselected on-surface-variant, disabled on-surface at 0.38. Hover, focus and press change no color: the tokens' {Selected,Unselected}{Hover,Focus,Pressed}IconColor are never rendered by Compose, so only the state layer shows, in the content color whether or not the radio is selected (Compose's ripple color; owner decisions M8). Label and group: Compose's RadioGroupSample, 56dp option rows in a column, the label 16dp after the (24dp) radio, body large, content color; the whole label selects and shows the radio's ripple.
radio-button.css sources
(compose/material3 @ 19991f2c, RadioButton.kt; values in radio-button.css / radio-button-defaults.css, lines in PARITY.md):
- Layout (L154-180): clickable → minimumInteractiveComponentSize (48dp, the input here), selectable (Role.RadioButton), then the IconSize (20dp) circle centred, inside RadioButtonPadding. Unbounded ripple of radius StateLayerSize / 2 with a CircleShape focus ring (L163-169): the 40dp state layer circle, the press grows from its centre.
- Drawing (L181-190): the ring is a circle of radius IconSize / 2 − stroke / 2 stroked at RadioStrokeWidth (2dp), so a 20dp circle with a 2dp border here; the dot is filled at animatedDotRadius − stroke / 2 while that radius is above 0 (nothing below), in the ring's color. dotRadius = RadioButtonDotSize / 2 (6dp) when selected, 0 otherwise (L91).
- Motion (L137-152): the dot radius on FastSpatial (a registered length on the dot, transitioned with the spring's linear() easing; it overshoots under the expressive scheme: the dot grows past 10dp and settles, and shrinking it passes 0 and draws nothing until it is gone). The color on DefaultEffects, snapping while disabled. Both carry upstream "TODO Load the motionScheme tokens from the component tokens file".
- Colors: defaultRadioButtonColors (L229-243): selected primary, unselected on-surface-variant, disabled on-surface at 0.38 (Color.copy(alpha)). Hover, focus and press change no color (the tokens' {Selected,Unselected}{Hover,Focus,Pressed}IconColor are never rendered by Compose): state layer only, in the content color (ripple() without a color), selected or not (owner decisions M8 #3, #4).
- Label and group: RadioGroupSample (RadioButtonSamples.kt L67-99): selectableGroup, 56dp option rows that fill the width, the label 16dp after the radio and bodyLarge in the content color. The sample's radio is not clickable (onClick = null: 24dp wide, the row is the target); here the input keeps its 48dp target, so the label starts 16dp after the radio's 24dp, i.e. 4dp after the target, and the whole label selects and shows the radio's ripple.