Stems UI
Menu

Radio Group

A set of options where only one can be selected.

Density: comfortable

Plan (required)

Submitted: (not yet)

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-group

Requires 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's RadioGroup):
    • value: the selected radio's value, or null for none. Controlled when not undefined.
    • 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', sets aria-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(): sets role="radiogroup", aria-disabled, aria-readonly, aria-required, aria-orientation, and data-disabled. Name it with aria-label or aria-labelledby.
  • RadioGroupChangeEvent: bubbling stems:radio-group-change event with the selected value, 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's name, form, and required; selecting a radio clicks it, so native input/change events fire. A controlled group whose owner ignores the change snaps back.
  • Base UI's change reason is always 'none', so Stems doesn't pass one.
  • Field: inside <field.Context> the group is the field's control. Its value (null until a radio is selected) is the field value; the field's name wins for the hidden radios; with required, the hidden radios report valueMissing until one is selected. The root gets the field state attributes, aria-labelledby (a field.label() outside any item), aria-describedby, and aria-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 a field.label() (Base UI's Field.Item). Moving focus between radios doesn't blur the field. The field's (or an enclosing fieldset.Context's) disabled disables every radio.
  • Without a name (on the group or the field), hidden radios are not one native group: with required, each unselected radio reports valueMissing to native validation of plain forms (inside form.root() and a field, only the selected radio's input is checked).
KeyAction
ArrowDown / ArrowRightFocus and select the next radio (wraps)
ArrowUp / ArrowLeftFocus and select the previous radio (wraps)
SpaceSelect the focused radio
TabMove into / out of the group (single tab stop)

Styling hooks

PartAttributePresent when
rootdata-disableddisabled 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's Radio.Root):
    • value (required): identifies the radio and is submitted while it is selected.
    • disabled: ignore interaction. Defaults to the group's disabled.
    • readOnly: don't let the user select it. Defaults to the group's readOnly.
    • required: defaults to the group's required.
    • 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(): sets role="radio", aria-checked, the roving tabIndex (0 for the group's tab stop, -1 otherwise, removed when disabled), the state data attributes, and click, Space, and arrow-key handling. On a <button> host it sets type="button" and native disabled; on other hosts aria-disabled. A <span> root gets aria-labelledby pointing at its hidden input's label after mount (unless it already has aria-label / aria-labelledby).
  • indicator({ keepMounted? }): sets aria-hidden="true" and the state data attributes. It is hidden while the radio isn't selected (after its exit animation) unless keepMounted is 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.

AttributePresent when
data-checkedThe radio is selected
data-uncheckedThe radio is not selected
data-disabledThe radio or its group is disabled
data-readonlyThe radio or its group is read-only
data-requiredThe radio or its group is required
data-starting-style / data-ending-styleIndicator only: while it animates in / out
data-valid / data-invalidIn a field: the group's field is valid / invalid
data-dirty / data-touchedIn a field: the group's value changed / focus left the group
data-filledIn a field: the group has a selected radio
data-focusedIn 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.