Radio
Radios
A native, grouped control with selected and disabled state.
Selected plan: basicimport { useState } from 'react'
import {
Radio,
Surface,
Text,
} from '@language-lit/material3-expressive'
const plans = [
{ value: 'basic', label: 'Basic' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
] as const
export function RadioExample() {
const [plan, setPlan] = useState<(typeof plans)[number]['value']>('basic')
return (
<Surface
as="section"
aria-labelledby="radio-example-title"
color="surface-container-low"
shape="extra-large"
className="radio-example"
>
<Text as="h2" id="radio-example-title" variant="titleLarge" emphasis="emphasized">
Radios
</Text>
<Text as="p" variant="bodyMedium">
A native, grouped control with selected and disabled state.
</Text>
<div className="radio-example__group" role="radiogroup" aria-label="Plan">
{plans.map((option) => (
<label key={option.value} className="radio-example__row">
<Radio
name="plan"
value={option.value}
checked={plan === option.value}
onCheckedChange={() => setPlan(option.value)}
/>
<Text as="span" variant="bodyLarge">
{option.label}
</Text>
</label>
))}
</div>
<div className="radio-example__group">
<label className="radio-example__row">
<Radio name="disabled-plan" value="unavailable" disabled />
<Text as="span" variant="bodyLarge">
Unavailable
</Text>
</label>
<label className="radio-example__row">
<Radio name="disabled-plan" value="selected-unavailable" disabled defaultChecked />
<Text as="span" variant="bodyLarge">
Unavailable and selected
</Text>
</label>
</div>
<Text as="span" role="status" variant="bodySmall" aria-live="polite">
Selected plan: {plan}
</Text>
</Surface>
)
}Radio is a native input type="radio" with current Material icon,
state-layer, and motion behavior. It owns no label, so it composes with
ordinary HTML labelling, and no group wrapper, so it composes with ordinary
native grouping.
import { Radio } from '@language-lit/material3-expressive'
import '@language-lit/material3-expressive/styles.css'
<label>
<Radio name="plan" value="pro" defaultChecked />
Pro plan
</label>
<label>
<Radio name="plan" value="team" />
Team plan
</label>Contract
- The rendered control is one native radio input. Its ref,
value,form,required,id, ARIA and data attributes, and native handlers are forwarded to that input. nameis required. Two or moreRadios that share anameform one native group: the browser enforces mutual exclusivity and moves both focus and selection with arrow keys, without any orchestration from this library.classNameandstyledescribe the radio root, which owns the 48px interaction target around the sourced 20px circle.checkedwithonCheckedChangeis controlled;defaultCheckedis uncontrolled and leaves checkedness to the DOM, including native form reset and native deselection by a sibling in the same group.onChangeruns before the library updates state, andpreventDefault()cancels that update.onCheckedChangereports the value the browser resolved, so it never needs a separate toggle calculation.
States and motion
| State | Ring and dot |
|---|---|
| unselected | on-surface-variant |
| selected | primary |
| disabled unselected | on-surface at 0.38 |
| disabled selected | on-surface at 0.38 |
Hover, focus, and pressed use the 40px circular state layer. The dot scales in and out with Expressive fast-spatial motion regardless of disabled state; the shared ring/dot color transitions with Expressive default-effects motion while enabled and snaps immediately while disabled, matching the source's own enabled-only color animation guard. Reduced motion makes every one of those changes immediate.
Current AndroidX RadioButton has no size, variant, or error parameter, so this implementation ships one form and does not invent Expressive geometry that the pinned source does not define.
Accessibility
The native control supplies role, checked state, required state, and keyboard
behavior: Space activates a focused, unchecked radio, arrow keys move focus
and selection within the shared name group, and Enter does not activate.
Naming comes from a wrapping label, label for, aria-label, or
aria-labelledby.
The 20px circle sits inside a 48px target. :focus-visible draws a
token-backed focus ring on the circle. Forced-colors mode keeps the ring
outline, uses Highlight for the selected ring/dot and focus ring, and GrayText
for disabled treatment. Layout is logical, so the control behaves correctly in
RTL.
Tokens and boundaries
Radio registers searchable --m3e-comp-radio-* variables for:
- minimum target, container size, outline width, dot size, and state-layer size;
- selected and unselected icon color, shared by the ring and the dot;
- disabled-selected and disabled-unselected icon color with their separate opacities;
- selected and unselected state-layer color, and the focus ring.
Theme overrides remain scoped to Material3Provider; Radio injects no runtime
styles. It imports no Next.js, Vite, router, animation library,
or private application code.