Skip to content

Selection

Radio button

Lets people select one option from a set.

Examples

Radio group

Native radios: one tab stop for the group, the arrow keys select. No JavaScript needed.

React HTML
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.