OperantAI Workflow Design System
ComfortableCompact

RadioGroup presents a set of mutually-exclusive options where exactly one can be selected. Per the component-selection table in AGENTS.md, use it for ≤4 mutually-exclusive options; for switching between views in one context use Tabs, and for a compact segmented choice use .ods-segmented instead.

Installation

HTML — already included if you loaded the 3 stylesheets — see the Installation guide.

React

npm install @operant/ui

@operant/ui is currently a monorepo workspace package, not yet published to the public npm registry.

Usage

HTML

<div class="ods-radio-group" role="radiogroup" aria-label="Run status">
  <label class="ods-radio-label" for="status-draft">
    <input class="ods-radio" type="radio" name="status" id="status-draft" checked>
    Draft
  </label>
  <label class="ods-radio-label" for="status-review">
    <input class="ods-radio" type="radio" name="status" id="status-review">
    In review
  </label>
  <label class="ods-radio-label" for="status-approved">
    <input class="ods-radio" type="radio" name="status" id="status-approved">
    Approved
  </label>
</div>

Note: .ods-radio is styled with appearance: none conventions elsewhere in this system, and pairs a small dot indicator (.ods-radio-indicator) that Radix renders as a separate element on [data-state="checked"]. In plain HTML with a native <input type="radio">, the checked dot is drawn by the border/background rules directly — no extra indicator element is required.

React

import { RadioGroup, RadioGroupItemField } from "@operant/ui";

export default function Example() {
  return (
    <RadioGroup aria-label="Run status" defaultValue="draft">
      <RadioGroupItemField value="draft" label="Draft" />
      <RadioGroupItemField value="review" label="In review" />
      <RadioGroupItemField value="approved" label="Approved" />
    </RadioGroup>
  );
}

Examples

Column (default)

The default orientation="column" stacks options vertically with --space-6 gap, matching .ods-radio-group's default flex-direction: column.

HTML

<div class="ods-radio-group" role="radiogroup" aria-label="Run status">
  <label class="ods-radio-label" for="c-draft"><input class="ods-radio" type="radio" name="c-status" id="c-draft" checked>Draft</label>
  <label class="ods-radio-label" for="c-review"><input class="ods-radio" type="radio" name="c-status" id="c-review">In review</label>
  <label class="ods-radio-label" for="c-approved"><input class="ods-radio" type="radio" name="c-status" id="c-approved">Approved</label>
</div>

React

<RadioGroup aria-label="Run status" defaultValue="draft">
  <RadioGroupItemField value="draft" label="Draft" />
  <RadioGroupItemField value="review" label="In review" />
  <RadioGroupItemField value="approved" label="Approved" />
</RadioGroup>

Row

Pass orientation="row" (React) or add the .-row modifier (HTML) to lay options out horizontally with --space-12 gap. On the React side this also switches Radix's internal orientation to "horizontal" so arrow-key navigation follows the same axis as the visual layout.

HTML

<div class="ods-radio-group -row" role="radiogroup" aria-label="Run status">
  <label class="ods-radio-label" for="r-draft"><input class="ods-radio" type="radio" name="r-status" id="r-draft" checked>Draft</label>
  <label class="ods-radio-label" for="r-review"><input class="ods-radio" type="radio" name="r-status" id="r-review">In review</label>
  <label class="ods-radio-label" for="r-approved"><input class="ods-radio" type="radio" name="r-status" id="r-approved">Approved</label>
</div>

React

<RadioGroup aria-label="Run status" defaultValue="draft" orientation="row">
  <RadioGroupItemField value="draft" label="Draft" />
  <RadioGroupItemField value="review" label="In review" />
  <RadioGroupItemField value="approved" label="Approved" />
</RadioGroup>

With a disabled item

Individual items can be disabled independently of the group. .ods-radio:disabled drops opacity to 50% and removes pointer events.

HTML

<div class="ods-radio-group" role="radiogroup" aria-label="Run status">
  <label class="ods-radio-label" for="d-draft"><input class="ods-radio" type="radio" name="d-status" id="d-draft" checked>Draft</label>
  <label class="ods-radio-label" for="d-review"><input class="ods-radio" type="radio" name="d-status" id="d-review" disabled>In review</label>
  <label class="ods-radio-label" for="d-approved"><input class="ods-radio" type="radio" name="d-status" id="d-approved">Approved</label>
</div>

React

<RadioGroup aria-label="Run status" defaultValue="draft">
  <RadioGroupItemField value="draft" label="Draft" />
  <RadioGroupItemField value="review" label="In review" disabled />
  <RadioGroupItemField value="approved" label="Approved" />
</RadioGroup>

Bare items (no label)

For cases where the visible label lives elsewhere (e.g. a table cell), render RadioGroupItem directly and supply an aria-label.

HTML

<div class="ods-radio-group -row" role="radiogroup" aria-label="Run status">
  <input class="ods-radio" type="radio" name="b-status" aria-label="Draft" checked>
  <input class="ods-radio" type="radio" name="b-status" aria-label="In review">
</div>

React

<RadioGroup aria-label="Run status" defaultValue="draft" orientation="row">
  <RadioGroupItem value="draft" aria-label="Draft" />
  <RadioGroupItem value="review" aria-label="In review" />
</RadioGroup>

API Reference

<RadioGroup>

Wraps Radix RadioGroupPrimitive.Root.

PropTypeDefaultDescription
orientation"column" | "row""column"Visual layout. Maps to the .-row modifier and to Radix's orientation ("vertical"/"horizontal") so arrow-key navigation matches the visual axis.
valuestring—Controlled selected value.
defaultValuestring—Uncontrolled initial value.
onValueChange(value: string) => void—Called when the selection changes.
disabledbooleanfalseDisables every item in the group.
classNamestring—Merged with .ods-radio-group.

<RadioGroupItem>

Wraps Radix RadioGroupPrimitive.Item + Indicator. Renders .ods-radio with an inner .ods-radio-indicator (only visible on [data-state="checked"]).

PropTypeDefaultDescription
valuestring—Required. The value this item represents.
disabledbooleanfalseDisables this single item.
idstring—Forwarded to the underlying element; pair with a <label htmlFor>.

<RadioGroupItemField>

Convenience wrapper pairing RadioGroupItem with an .ods-radio-label, auto-generating an id/htmlFor pair via useId() when none is given.

PropTypeDefaultDescription
labelReactNode—Required. Label content rendered next to the control.
...restRadioGroupItemProps—All RadioGroupItem props (value, disabled, id, etc.).

HTML-side classes: .ods-radio-group (container, .-row modifier for horizontal layout), .ods-radio (the control itself — a hairline-bordered circle that gains a 2px --color-primary border on [data-state="checked"]), .ods-radio-indicator (the 8px filled dot), .ods-radio-label (label + control row, shared with .ods-checkbox-label).

Accessibility

  • Renders with the native ARIA radiogroup/radio roles via Radix, so screen readers announce the group label and each option's checked state correctly.
  • Full keyboard support: arrow keys move selection along the group's orientation axis, Tab moves in/out of the group as a single stop (roving tabindex).
  • RadioGroup requires an accessible name — pass aria-label (as in every example above) or associate a visible <label>/heading via aria-labelledby.
  • Focus is visible via :focus-visible → --shadow-focus on the currently-focused radio; disabled items are skipped during keyboard navigation.
  • Each RadioGroupItemField wires its own id/htmlFor pair automatically, so clicking the label text toggles the radio — no manual wiring needed.