Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
SelectionUpdatedFreeWCAG 2.2 AA

Radio

Single-pick radio group — five sizes, RBAC-aware.

Preview

Live preview
@oshon-ai/components

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add radio

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Radio } from '@oshon-ai/components';

export default function Example() {
  return <Radio />;
}

Default

tsx
<RadioGroup size="m" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
      <Radio value="basic" label="Basic" />
      <Radio value="pro" label="Pro" />
      <Radio value="enterprise" label="Enterprise" />
    </RadioGroup>

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="xs" data-story-size="xs">
        <RadioGroup size="xs" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
          <Radio value="basic" label="Basic" />
          <Radio value="pro" label="Pro" />
          <Radio value="enterprise" label="Enterprise" />
        </RadioGroup>
      </div>
      <div key="s" data-story-size="s">
        <RadioGroup size="s" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
          <Radio value="basic" label="Basic" />
          <Radio value="pro" label="Pro" />
          <Radio value="enterprise" label="Enterprise" />
        </RadioGroup>
      </div>
      <div key="m" data-story-size="m">
        <RadioGroup size="m" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
          <Radio value="basic" label="Basic" />
          <Radio value="pro" label="Pro" />
          <Radio value="enterprise" label="Enterprise" />
        </RadioGroup>
      </div>
      <div key="l" data-story-size="l">
        <RadioGroup size="l" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
          <Radio value="basic" label="Basic" />
          <Radio value="pro" label="Pro" />
          <Radio value="enterprise" label="Enterprise" />
        </RadioGroup>
      </div>
      <div key="mobile" data-story-size="mobile">
        <RadioGroup size="mobile" name="demo-plan" defaultValue="basic" aria-label="Choose a plan">
          <Radio value="basic" label="Basic" />
          <Radio value="pro" label="Pro" />
          <Radio value="enterprise" label="Enterprise" />
        </RadioGroup>
      </div>
    </div>

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Radio
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

control

20×20 (at size `m`) wrapper that hosts the ring, the hover bleed, and the hidden native `<input type="radio">`. Carries `data-oshon-component="radio"` plus `data-oshon-state`, `data-oshon-size`, and `data-oshon-disabled` for QA hooks.

ring

The visible circle. Reads `data-state` to swap between the four states. The border-color is the entire chrome — a filled radio is just an empty ring with the inner dot opaque, matching the Figma "Filled" frames.

dot

Inner circle that fades in when filled. Sized 8×8 at `m` (40 % of the 20px outer ring). Reads the same `data-state` so its background and opacity flip together with the ring.

hover-bleed

Pseudo wash that surfaces on `:hover` or `:focus-within`. Uses `bg-[var(--oshon-color-neutral-100)]` with `mix-blend-multiply` so it tints whatever surface hosts the radio — exact match to the Figma `9341:34860` Hovered frame. Hidden when `disabled` is true.

label

Optional visible label rendered to the right of the control with the size-token gap between them. Rendered inside the outer `<label>` so the whole row is a click target.

description

Optional secondary line under the label. Rendered in `--oshon-color-neutral-600` at one font-size step below the label. Wired to the input via `aria-describedby` so screen readers announce it after the label.

Keyboard

Hosts a real native `<input type="radio">` (visually hidden, layered on the ring) so all native keyboard semantics apply: Tab focuses into the group; Arrow keys move between radios in the group; Space activates the focused radio. RadioGroup wraps its children in `role="radiogroup"`. The label is bound via `<label htmlFor>` so clicking the visible label or the ring selects the radio. Visible focus ring is `:focus-visible` only.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-28

Do / Don't

✓ Do

RadioGroup (controlled)
<RadioGroup name="plan" value={plan} onValueChange={setPlan}>
  <Radio value="basic" label="Basic" />
  <Radio value="pro" label="Pro" />
  <Radio value="ent" label="Enterprise" />
</RadioGroup>
RadioGroup (uncontrolled with default)
<RadioGroup name="theme" defaultValue="dark">
  <Radio value="light" label="Light" />
  <Radio value="dark" label="Dark" />
</RadioGroup>
Standalone radio (native form submission via name)
<Radio name="plan" value="basic" label="Basic" defaultChecked />
With description (form-row pattern)
<Radio
  name="cadence"
  value="weekly"
  label="Weekly digest"
  description="A summary every Friday morning"
/>

✗ Don't

Faking the control with a styled <div>
<div role="radio" aria-checked={selected} onClick={pick}>…</div>

Loses native form submission, browser-level focus management, the canonical Arrow-key navigation across a group, and the `Space` activation binding. Always render the real `<input type="radio">` (which Radio does for you).

Forgetting the `name` attribute on standalone radios
<Radio value="basic" label="Basic" />

Without `name` the browser does not group the radios for native mutual exclusion, and form submission cannot post the selected value. Either wrap the radios in a `<RadioGroup name="…">` (preferred) or pass `name` directly to every `<Radio>`.

Using two `<Radio>`s with the same `name` outside a RadioGroup
<Radio name="plan" value="basic" label="Basic" />
<Radio name="plan" value="pro" label="Pro" />

Native HTML mutual exclusion will work, but selection state is owned by the browser — there is no React callback driving an app-level value. Either use uncontrolled with a form `onSubmit` reading the FormData, or upgrade to `<RadioGroup>` so React owns the selection state.

Hardcoding a hex color on the ring
<Radio slotClassName={{ ring: "border-[#0066ff]" }} />

Bypassing the primary-700 token defeats white-label theming (principle #6). Override the slot only when you need to attach behavior; leave color decisions to the tokens.

Design rationale

Radio is the second of two primitive selection controls in the Oshon DS (alongside Checkbox). The Figma source authors a single 20×20 surface with five published states; we collapse those into a single `data-state` attribute on the ring + dot so the entire visual axis can be reasoned about as a state machine. The hidden native `<input type="radio">` layered on top of the visual ring is the cornerstone — it owns focus, native arrow-key navigation, form participation, and screen-reader semantics, while the visual chrome stays pure presentation. RadioGroup is a thin orchestration wrapper that exists for one reason: native HTML radios share state by `name`, but React apps want to drive selection from a controlled value. The group swaps that contract — the React `value` prop is the single source of truth, and every child Radio reads its `checked` state from `group.value === radio.value` via context. Five-size axis anchors at the published 20×20 default with the 8×8 inner dot (40 % of the ring); xs/s shrink the dot proportionally so the inner circle never reads larger than half the ring at any size.