Radio Group
A set of options where only one can be selected.
Density: comfortable
Codeapp/demos/public/radio-group.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { RadioGroup, RadioGroupItem } from '@stemsui/registry/ui/radio-group.tsx'
const densities = [
{ value: 'default', label: 'Default' },
{ value: 'comfortable', label: 'Comfortable' },
{ value: 'compact', label: 'Compact' },
]
export const RadioGroupDemo = clientEntry(import.meta.url, function RadioGroupDemo(handle: Handle) {
let density = 'comfortable'
let submitted: string | null = null
return () => (
<div class="flex w-full max-w-sm flex-col gap-8">
<div class="flex flex-col gap-3">
<RadioGroup
aria-label="Density"
value={density}
onValueChange={(value) => {
density = value
void handle.update()
}}
>
{densities.map((option) => (
<div class="flex items-center gap-3">
<RadioGroupItem value={option.value} id={`density-${option.value}`} />
<label
for={`density-${option.value}`}
class="text-sm leading-none font-medium select-none"
>
{option.label}
</label>
</div>
))}
</RadioGroup>
<p class="text-sm text-muted-foreground" data-testid="radio-group-value">
Density: <code class="font-mono text-foreground">{density}</code>
</p>
</div>
<form
class="flex flex-col gap-4 rounded-lg border p-4"
mix={on('submit', (event) => {
event.preventDefault()
let entries = [...new FormData(event.currentTarget).entries()]
submitted = entries.map(([key, value]) => `${key}=${String(value)}`).join('&')
void handle.update()
})}
>
<div id="plan-label" class="text-sm font-medium">
Plan (required)
</div>
<RadioGroup name="plan" required aria-labelledby="plan-label">
<label class="flex items-center gap-3 text-sm select-none">
<RadioGroupItem value="free" />
Free
</label>
<label class="flex items-center gap-3 text-sm select-none">
<RadioGroupItem value="pro" />
Pro
</label>
<label class="flex items-center gap-3 text-sm text-muted-foreground select-none">
<RadioGroupItem value="enterprise" disabled />
Enterprise (unavailable)
</label>
</RadioGroup>
<button
type="submit"
class="inline-flex h-8 items-center justify-center self-start rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Continue
</button>
<p class="text-sm text-muted-foreground" data-testid="radio-form-result">
Submitted: <code class="font-mono text-foreground">{submitted ?? '(not yet)'}</code>
</p>
</form>
</div>
)
})
Installation
npx shadcn add @stemsui/radio-groupRequires the one-time setup. Behavior: stems-ui/radio-group, stems-ui/radio.
API reference
The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.
stems-ui/radio-group
radioGroup owns the selected value of a set of radios from stems-ui/radio:
a single tab stop, arrow-key selection, and hidden native radios for forms. App code owns the
markup and visual styles.
Base UI equivalent: Radio Group (RadioGroup
and Radio). Like Base UI, the group and the radio are separate modules.
Usage
import * as radio from 'stems-ui/radio'
import * as radioGroup from 'stems-ui/radio-group'
function StorageType() {
return () => (
<radioGroup.Context name="storage" defaultValue="ssd">
<div aria-labelledby="storage-label" mix={radioGroup.root()}>
<div id="storage-label">Storage type</div>
{['ssd', 'hdd'].map((value) => (
<label>
<radio.Context value={value}>
<span class="radio" mix={radio.root()}>
<span class="radio-indicator" mix={radio.indicator()} />
</span>
</radio.Context>
{value.toUpperCase()}
</label>
))}
</div>
</radioGroup.Context>
)
}
Listen for changes on any ancestor:
<div mix={radioGroup.onRadioGroupChange((event) => console.log(event.value))}>{/* group */}</div>
stems-ui/radio-group
Context: owns the selected value. Props (same as Base UI'sRadioGroup):value: the selected radio's value, ornullfor none. Controlled when notundefined.defaultValue: the initially selected value (uncontrolled).onValueChange(value, event): called with the selected value and the DOM event that caused it.name: form field name, shared by every radio's hidden input.form: id of the form that owns the hidden inputs.disabled: disable every radio (and the hidden inputs).readOnly: show the selection but don't let the user change it.required: a radio must be selected for the form to be valid (native constraint validation).orientation:'horizontal'or'vertical', setsaria-orientation. Arrow keys on both axes work either way, as in the WAI-ARIA APG radio group (Base UI has no such prop).handle.context.get(radioGroup.Context)returns{ value, name, form, disabled, readOnly, required, orientation }.
root(): setsrole="radiogroup",aria-disabled,aria-readonly,aria-required,aria-orientation, anddata-disabled. Name it witharia-labeloraria-labelledby.RadioGroupChangeEvent: bubblingstems:radio-group-changeevent with the selectedvalue, dispatched from the selected radio.onRadioGroupChange(handler, capture?): typed listener mixin for the event.
Behavior notes
- Focus: the radios form one tab stop: the selected radio, or the first enabled one when nothing (or a disabled radio) is selected. Disabled radios are skipped.
- Keyboard: arrow keys move focus to the next or previous enabled radio, wrapping around, and
select it (unless the group is read-only). Left/Right are mirrored in RTL (
dir="rtl"read at key time). Space selects the focused radio. Enter does nothing (default prevented). - Forms: each radio renders a hidden
<input type="radio">with the group'sname,form, andrequired; selecting a radio clicks it, so nativeinput/changeevents fire. A controlled group whose owner ignores the change snaps back. - Base UI's change
reasonis always'none', so Stems doesn't pass one. - Field: inside
<field.Context>the group is the field's control. Its value (nulluntil a radio is selected) is the field value; the field'snamewins for the hidden radios; withrequired, the hidden radios reportvalueMissinguntil one is selected. The root gets the field state attributes,aria-labelledby(afield.label()outside any item),aria-describedby, andaria-invalid; the selected (or first enabled) radio is focused when the field blocks a submission. Wrap each radio in<field.ItemContext>to label it with afield.label()(Base UI'sField.Item). Moving focus between radios doesn't blur the field. The field's (or an enclosingfieldset.Context's)disableddisables every radio. - Without a
name(on the group or the field), hidden radios are not one native group: withrequired, each unselected radio reportsvalueMissingto native validation of plain forms (insideform.root()and a field, only the selected radio's input is checked).
| Key | Action |
|---|---|
ArrowDown / ArrowRight | Focus and select the next radio (wraps) |
ArrowUp / ArrowLeft | Focus and select the previous radio (wraps) |
Space | Select the focused radio |
Tab | Move into / out of the group (single tab stop) |
Styling hooks
| Part | Attribute | Present when |
|---|---|---|
| root | data-disabled | disabled is set (or the field / fieldset is disabled) |
Radio and indicator attributes are listed in stems-ui/radio. Inside a
field, the root also gets the field state attributes (data-valid, data-invalid,
data-dirty, data-touched, data-filled, data-focused; a Stems extra, Base UI documents only
data-disabled on the group) and aria-invalid="true" while invalid.
stems-ui/radio
radio wires one radio button (role="radio") inside a
stems-ui/radio-group, with an indicator and a hidden
<input type="radio"> for forms. App code owns the markup and visual styles.
Base UI equivalent: Radio (Radio.Root,
Radio.Indicator).
Usage
import * as radio from 'stems-ui/radio'
import * as radioGroup from 'stems-ui/radio-group'
;<radioGroup.Context name="storage">
<div aria-label="Storage" mix={radioGroup.root()}>
<label>
<radio.Context value="ssd">
<span mix={radio.root()}>
<span mix={radio.indicator()} />
</span>
</radio.Context>
SSD
</label>
</div>
</radioGroup.Context>
Sibling label: pass id to <radio.Context> for a <span> root (it goes on the hidden input),
or put id on a <button> root.
stems-ui/radio
Context: per-radio provider. Must be inside<radioGroup.Context>. Renders the hidden<input type="radio">right after its children. Props (same as Base UI'sRadio.Root):value(required): identifies the radio and is submitted while it is selected.disabled: ignore interaction. Defaults to the group'sdisabled.readOnly: don't let the user select it. Defaults to the group'sreadOnly.required: defaults to the group'srequired.id: id of the hidden input, for<label for>with a non-button root.inputRef(input, signal): receives the hidden radio.handle.context.get(radio.Context)returns{ value, checked, disabled, readOnly, required }.
root(): setsrole="radio",aria-checked, the rovingtabIndex(0for the group's tab stop,-1otherwise, removed when disabled), the state data attributes, and click, Space, and arrow-key handling. On a<button>host it setstype="button"and nativedisabled; on other hostsaria-disabled. A<span>root getsaria-labelledbypointing at its hidden input's label after mount (unless it already hasaria-label/aria-labelledby).indicator({ keepMounted? }): setsaria-hidden="true"and the state data attributes. It ishiddenwhile the radio isn't selected (after its exit animation) unlesskeepMountedis set.
Base UI's nativeButton / render props map to choosing a <button> host for root().
Styling hooks
Root and indicator get the same attributes. Boolean attributes are present ("") or absent.
| Attribute | Present when |
|---|---|
data-checked | The radio is selected |
data-unchecked | The radio is not selected |
data-disabled | The radio or its group is disabled |
data-readonly | The radio or its group is read-only |
data-required | The radio or its group is required |
data-starting-style / data-ending-style | Indicator only: while it animates in / out |
data-valid / data-invalid | In a field: the group's field is valid / invalid |
data-dirty / data-touched | In a field: the group's value changed / focus left the group |
data-filled | In a field: the group has a selected radio |
data-focused | In a field: focus is inside the group |
Inside a <field.ItemContext>, the radio's hidden input gets the item's control id, the root is
aria-labelledby the item's field.label() and aria-describedby its field.description(), and
the item's disabled disables the radio. role="radio" doesn't support aria-invalid; the
radio group gets it.