Skip to content

Radio Button

Allows users to select exactly one option from a set of mutually exclusive choices.

  • Radio
  • Option Button

Overview

The Radio Button is a form input that lets users select exactly one option from a mutually exclusive set. Unlike a Checkbox, which allows multiple selections, radio buttons enforce a single-choice constraint — selecting one option automatically deselects the previously selected option in the same group.

Radio buttons always operate as a group. A lone radio button is an anti-pattern because once selected, the user cannot deselect it without choosing a different option (there's no "uncheck"). If you need a single binary toggle, use a Switch or Checkbox instead.

The name "radio button" comes from the mechanical preset buttons on old car radios: pressing one button would pop out the previously pressed button. This physical metaphor — mutually exclusive selection — maps directly to the digital component.

When to use Radio Buttons:

  • Selecting exactly one option from 2–7 visible choices
  • When users need to see and compare all options simultaneously
  • Form fields where the choice is required (one must always be selected)
  • Settings or preferences with mutually exclusive values

When NOT to use Radio Buttons:

Radio buttons should almost always have a default selection. Leaving all options unselected forces the user to make a choice they might not understand yet, and it creates validation complexity. If "no choice" is a valid state, consider adding an explicit "None" or "No preference" option.

Use the Contrast Checker to verify that the selected indicator (filled circle) meets 3:1 non-text contrast against the radio's background (WCAG 1.4.11).

Variants

Visual Variants

VariantDescriptionCommon In
StandardCircular outline with a filled inner circle when selected. The classic radio.Every platform and design system
Bordered / CardEach option is wrapped in a bordered container that highlights on selection.Pricing plans, shipping methods, payment options
Tile / BlockFull-width selectable cards with icons, descriptions, and visual emphasis.Onboarding flows, product configuration
Button GroupOptions styled as a segmented button bar. Visually identical to a Button Group.Toolbars, compact filters, alignment pickers
Custom IconThe radio circle is replaced with a custom icon or illustration.Theme pickers, avatar selectors

Size Variants

SizeCircle DiameterInner DotLabel SizeUse Case
sm16px8px13pxDense forms, table rows, sidebars
md20px10px14pxDefault for most form contexts
lg24px12px16pxMobile-first, touch-optimized, prominent choices

Orientation

LayoutDescriptionBest For
VerticalOptions stacked top-to-bottom. Default.Most forms — easy scanning and comparison
HorizontalOptions placed side-by-side.2–4 short-label options with ample horizontal space
GridOptions arranged in a 2D grid of tiles or cards.Visual selectors (colors, themes, plans)

The bordered/card variant is particularly effective for high-stakes choices like pricing tiers or shipping methods. Wrapping each option in a visible container increases the hit target (WCAG 2.5.8 Target Size) and makes the currently-selected option scannable at a glance.

Properties

Radio Group Properties

PropertyTypeDefaultDescription
namestring—Required. Groups radios together. All radios sharing the same name form a mutually exclusive set.
valuestring—The currently selected value (controlled mode)
defaultValuestring—Initial selected value (uncontrolled mode)
onChange(value: string) => void—Callback fired when selection changes
orientation'vertical' | 'horizontal''vertical'Layout direction of the radio items
disabledbooleanfalseDisables all radios in the group
requiredbooleanfalseMakes selection mandatory for form validation
labelstring—Visible label for the radio group (rendered as <legend> inside <fieldset>)
errorstring—Error message displayed below the group

Individual Radio Properties

PropertyTypeDefaultDescription
valuestring—Required. The value this radio represents
labelstring | ReactNode—The visible label next to the radio circle
descriptionstring—Secondary text below the label (for card variants)
disabledbooleanfalseDisables this specific radio option
idstringautoThe HTML id attribute. Auto-generated if not provided.

Critical: The name attribute is what creates the mutually exclusive group in native HTML. Without a shared name, radios won't deselect each other. In React component libraries, the parent RadioGroup component typically manages this automatically.

Token Mappings

Design Token Mappings

Token CategoryToken ExampleRadio Usage
Color – Border--color-neutral-400Unselected radio circle border
Color – Primary--color-primary-600Selected radio fill and border
Color – Dot--color-whiteInner dot on selected state
Color – Error--color-error-600Border color in error/invalid state
Color – Disabled--color-neutral-200Border and fill when disabled
Color – Label--color-neutral-900Radio label text
Color – Description--color-neutral-500Secondary description text
Spacing – Gap--space-2 (8px)Gap between radio circle and label
Spacing – Group Gap--space-3 (12px)Vertical spacing between radio items
Border Width--border-width-2 (2px)Radio circle border thickness
Border Radius50%Always circular — not token-driven
Focus Ring--color-focus-ring, --focus-ring-offsetKeyboard focus indicator
Transition--duration-fast (150ms)Selection state change animation
Shadow--shadow-smCard variant selected state elevation

The radio circle itself is always perfectly round (border-radius: 50%), so the Border Radius Generator doesn't apply to the radio control. However, for card/tile variants, the container card's corner radius is fully token-driven and customizable.

States

Interactive States

StateVisual TreatmentBehavior
Default (Unselected)Empty circle with neutral border. Label in default text color.Ready for selection.
Hover (Unselected)Border darkens or background tint appears. Cursor: pointer.Indicates interactivity.
SelectedCircle filled with primary color and white inner dot. Border matches primary.Represents the active choice.
Hover (Selected)Slightly darker primary fill.Confirms the element is interactive even when selected.
Focus-Visible2px focus ring with offset around the radio circle (or the entire card in card variants).Keyboard navigation indicator. Must meet WCAG 2.4.13 (Focus Appearance).
Disabled (Unselected)Muted border and label. Cursor: not-allowed.Not interactive. Removed from tab order unless using aria-disabled.
Disabled (Selected)Muted primary fill with reduced-opacity dot.Shows the locked-in selection.
Error / InvalidRed border on all radios in the group. Error message below.Validation failed — the user must make a selection.
Read-onlySelected state visible but no hover/click response.Shows the value without allowing changes (less common for radios).

State Transitions

The selection transition should be snappy — 100–150ms with ease-out. The inner dot can scale from 0 to 1 for a satisfying "pop" effect. Avoid elaborate animations; radio selection must feel instantaneous because users are comparing options rapidly.

For card variants, the border color and optional shadow transition should be similarly fast. A box-shadow animation (e.g., adding --shadow-sm) on the selected card gives tactile feedback without feeling sluggish.

Accessibility

Accessibility Requirements

Radio buttons have excellent native HTML support — when you use <input type="radio"> correctly, the browser handles most accessibility behavior for free.

Semantic Structure (WCAG 1.3.1 Info and Relationships):

  • Wrap the group in a <fieldset> with a <legend> that describes the question or label. Screen readers announce the legend before each option — "Shipping method: Standard, radio button, 1 of 3."
  • Each <input type="radio"> must have an associated <label> via for/id pairing or wrapping.
  • All radios in the group share the same name attribute.

Keyboard Interaction (WCAG 2.1.1 Keyboard):

  • Tab moves focus to the radio group (landing on the selected radio, or the first radio if none is selected).
  • Arrow Up / Arrow Left moves to the previous option and selects it.
  • Arrow Down / Arrow Right moves to the next option and selects it.
  • Arrow keys loop — pressing Down on the last option wraps to the first.
  • Space selects the focused radio (though arrow keys already select, so Space is redundant but expected).

This "roving tabindex" pattern means the entire radio group is a single tab stop, which is more efficient than tabbing through every option individually.

Focus Visibility (WCAG 2.4.7 / 2.4.13):

  • The focused radio must show a visible focus ring — at least 2px solid with an offset from the control.
  • For card variants, the focus ring should surround the entire card, not just the radio circle inside it.

Error Identification (WCAG 3.3.1 / 3.3.3):

  • When the group is required and no selection is made, display an error message associated with the group via aria-describedby on the <fieldset> pointing to the error text.
  • Don't rely solely on red color to indicate errors (WCAG 1.4.1 Use of Color) — include an error icon and text.

Non-Text Contrast (WCAG 1.4.11):

  • The radio circle border must have ≥3:1 contrast against the surrounding background.
  • The filled selected state must also meet 3:1. Use the Contrast Checker to verify your primary color against the form background.

Target Size (WCAG 2.5.8):

  • The clickable area for each radio option (including its label) should be at least 24×24px (Level AA). Card variants naturally exceed this. For standard radios, ensure the label is part of the click target by using proper <label> association.

Custom Radios: If you replace the native radio with a custom-styled element, you must:

  1. Apply role="radiogroup" to the container and role="radio" to each option.
  2. Manage aria-checked="true|false" on each option.
  3. Implement roving tabindex (tabindex="0" on the selected/focused item, tabindex="-1" on others).
  4. Handle all keyboard events (Arrow keys, Space).
  5. Ensure focus is visible and programmatically managed.

Native <input type="radio"> with CSS hiding (appearance: none + custom styling) is almost always preferable to the ARIA approach. You get keyboard behavior, form integration, and screen reader support for free.

Usage Guidelines

Do's

  • Always group radios — a single radio is meaningless. Minimum 2 options.
  • Pre-select a default when possible. If no default makes sense, add an explicit "None" option.
  • Use a <fieldset> and <legend> to label the group. "Shipping method:" as the legend, not a heading above the group.
  • Keep labels short — one line per option. Use the description slot for additional detail.
  • List options in a logical order — alphabetical, by popularity, by ascending value, or by user preference data.
  • Make the entire row clickable — not just the circle. Label association and adequate padding handle this.

Don'ts

  • Don't use radios for actions — they're for selection, not execution.
  • Don't nest radios — revealing sub-options based on a radio selection should use indented form fields, not nested radio groups.
  • Don't dynamically change other radios — each group should be independent. If selection A hides Group B, use conditional rendering rather than disabling radios.
  • Don't use more than ~7 options — beyond that, switch to a Select or Combobox.
  • Don't mix radio and checkbox patterns — if some options are multi-select and others are single-select, redesign the form.
  • Don't disable individual options without explanation — a tooltip or helper text should explain why an option is unavailable.

Card Radio Best Practices

When using bordered card variants for high-stakes selections (pricing tiers, plans):

  • Include a visual indicator (filled circle or checkmark) in addition to the border highlight.
  • Show price, features, or descriptions within the card to reduce cognitive load.
  • Highlight the recommended option with a badge or subtle background differentiation.

Code Snippets

html
<!-- Radio Group: Shipping Method -->
<fieldset class="radio-group" role="radiogroup" aria-required="true">
  <legend class="radio-group__legend">Shipping Method</legend>

  <div class="radio-group__options">
    <label class="radio" for="ship-standard">
      <input
        type="radio"
        id="ship-standard"
        name="shipping"
        value="standard"
        checked
        class="radio__input"
      />
      <span class="radio__control" aria-hidden="true"></span>
      <span class="radio__label">Standard (5–7 days)</span>
    </label>

    <label class="radio" for="ship-express">
      <input
        type="radio"
        id="ship-express"
        name="shipping"
        value="express"
        class="radio__input"
      />
      <span class="radio__control" aria-hidden="true"></span>
      <span class="radio__label">Express (2–3 days)</span>
    </label>

    <label class="radio" for="ship-overnight">
      <input
        type="radio"
        id="ship-overnight"
        name="shipping"
        value="overnight"
        class="radio__input"
      />
      <span class="radio__control" aria-hidden="true"></span>
      <span class="radio__label">Overnight (next day)</span>
    </label>
  </div>
</fieldset>

<style>
.radio-group__legend {
  font-weight: 600;
  font-size: 0.875rem;
  margin-bottom: 0.5rem;
  color: var(--color-neutral-900);
}

.radio-group__options {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.radio {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  position: relative;
}

.radio__input {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.radio__control {
  width: 20px;
  height: 20px;
  border: 2px solid var(--color-neutral-400);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transition: border-color 150ms ease, background-color 150ms ease;
}

.radio__control::after {
  content: "";
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--color-primary-600);
  transform: scale(0);
  transition: transform 150ms ease;
}

.radio__input:checked + .radio__control {
  border-color: var(--color-primary-600);
}

.radio__input:checked + .radio__control::after {
  transform: scale(1);
}

.radio__input:focus-visible + .radio__control {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
}

.radio__input:disabled + .radio__control {
  border-color: var(--color-neutral-200);
  background: var(--color-neutral-100);
  cursor: not-allowed;
}

.radio__input:disabled ~ .radio__label {
  color: var(--color-neutral-400);
}

.radio__label {
  font-size: 0.875rem;
  color: var(--color-neutral-800);
  user-select: none;
}
</style>
tsx
import React, { createContext, useContext, useState, useId } from "react";

interface RadioGroupContextValue {
  name: string;
  value: string;
  onChange: (value: string) => void;
  disabled?: boolean;
}

const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);

interface RadioGroupProps {
  name?: string;
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  disabled?: boolean;
  orientation?: "vertical" | "horizontal";
  label: string;
  error?: string;
  required?: boolean;
  children: React.ReactNode;
}

export function RadioGroup({
  name,
  value: controlledValue,
  defaultValue = "",
  onChange,
  disabled = false,
  orientation = "vertical",
  label,
  error,
  required = false,
  children,
}: RadioGroupProps) {
  const autoId = useId();
  const groupName = name ?? autoId;
  const errorId = error ? `${groupName}-error` : undefined;
  const [internal, setInternal] = useState(defaultValue);
  const value = controlledValue ?? internal;

  const handleChange = (val: string) => {
    setInternal(val);
    onChange?.(val);
  };

  return (
    <RadioGroupContext.Provider
      value={{ name: groupName, value, onChange: handleChange, disabled }}
    >
      <fieldset
        role="radiogroup"
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={errorId}
        style={{ border: "none", padding: 0, margin: 0 }}
      >
        <legend style={{ fontWeight: 600, fontSize: "0.875rem", marginBottom: "0.5rem" }}>
          {label}
          {required && <span aria-hidden="true"> *</span>}
        </legend>
        <div
          style={{
            display: "flex",
            flexDirection: orientation === "horizontal" ? "row" : "column",
            gap: orientation === "horizontal" ? "1.5rem" : "0.75rem",
          }}
        >
          {children}
        </div>
        {error && (
          <p id={errorId} role="alert" style={{ color: "var(--color-error-600)", fontSize: "0.8125rem", marginTop: "0.5rem" }}>
            {error}
          </p>
        )}
      </fieldset>
    </RadioGroupContext.Provider>
  );
}

interface RadioProps {
  value: string;
  label: string;
  description?: string;
  disabled?: boolean;
}

export function Radio({ value, label, description, disabled: localDisabled }: RadioProps) {
  const ctx = useContext(RadioGroupContext);
  if (!ctx) throw new Error("Radio must be used within RadioGroup");

  const id = useId();
  const isDisabled = localDisabled || ctx.disabled;
  const isSelected = ctx.value === value;

  return (
    <label
      htmlFor={id}
      style={{
        display: "flex",
        alignItems: "flex-start",
        gap: "0.5rem",
        cursor: isDisabled ? "not-allowed" : "pointer",
        opacity: isDisabled ? 0.5 : 1,
      }}
    >
      <input
        type="radio"
        id={id}
        name={ctx.name}
        value={value}
        checked={isSelected}
        disabled={isDisabled}
        onChange={() => ctx.onChange(value)}
        style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
      />
      <span
        aria-hidden="true"
        style={{
          width: 20,
          height: 20,
          borderRadius: "50%",
          border: `2px solid ${isSelected ? "var(--color-primary-600)" : "var(--color-neutral-400)"}`,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexShrink: 0,
          marginTop: 2,
        }}
      >
        {isSelected && (
          <span
            style={{
              width: 10,
              height: 10,
              borderRadius: "50%",
              background: "var(--color-primary-600)",
            }}
          />
        )}
      </span>
      <div>
        <span style={{ fontSize: "0.875rem", color: "var(--color-neutral-800)" }}>{label}</span>
        {description && (
          <span style={{ display: "block", fontSize: "0.8125rem", color: "var(--color-neutral-500)", marginTop: 2 }}>
            {description}
          </span>
        )}
      </div>
    </label>
  );
}

Design Systems

Design System Implementations

Material Design 3 uses a 20px radio with a 10px inner dot and a "state layer" — a circular ripple that expands behind the control on hover (8% opacity) and press (12% opacity). Selected radios use the primary color for both the outer ring and inner dot. Material 3 introduced a subtle spring animation for the dot scaling in. The touch target is 48px minimum, achieved through transparent padding around the 20px control.

Ant Design provides Radio and Radio.Group with optionType="button" for segmented button-style radios. It supports buttonStyle="solid" (filled selected state) or buttonStyle="outline" (border-only selected state). Ant's radios support size (small, middle, large) and integrate directly with Form.Item for validation. The default radio uses a smooth CSS scale transition on the inner dot.

Radix UI offers an unstyled RadioGroup primitive with Root, Item, and Indicator sub-components. It handles roving tabindex, arrow-key navigation, and all ARIA attributes automatically. You supply all styling. The Indicator component renders only when the radio is selected, making animated entry straightforward with CSS.

Chakra UI provides RadioGroup and Radio with colorScheme, size (sm, md, lg), and isDisabled/isInvalid props. Chakra uses a CSS ::before pseudo-element for the inner dot with a scale transform, and applies a blue focus ring via its focus-visible system.

Headless UI (Tailwind Labs) offers a RadioGroup component built entirely with ARIA. It uses RadioGroup, RadioGroup.Option, RadioGroup.Label, and RadioGroup.Description. Selection is managed by value/onChange, and each option exposes render props (checked, active, disabled) for conditional styling. It's the most flexible headless implementation for card-style radios.

React Aria (Adobe) provides useRadioGroup and useRadio hooks with complete ARIA compliance, roving tabindex, and form integration. It handles label association, required validation, error messages, and internationalized announcements. The hooks work with any visual representation.

For verifying radio indicator contrast against backgrounds, use the Contrast Checker.

Data InputRadioOption Button