Loading Developer Playground

Loading ...

Skip to main content
ARIA ROLEWidget Roles

radio

A checkable input in a group of radio roles, only one of which can be checked at a time. Radio buttons allow users to select exactly one option from a set of mutually exclusive choices.

Required Container
radiogroup
Keyboard Navigation
Arrow Keys, Space
Native HTML Equivalent
<input type="radio">

Overview

The radio role defines a checkable input in a set where only one radio can be checked at a time. Radios must be grouped together within a radiogroup container, and the group enforces the mutual exclusivity—when one radio is selected, any previously selected radio becomes deselected.

Key Behavior: Unlike checkboxes where multiple options can be selected, radio buttons require exactly one selection from the group. Once a selection is made, users cannot deselect all options—they can only change their selection to a different option.

Native <input type="radio"> vs role="radio"

Native HTML radio inputs provide built-in keyboard navigation, automatic grouping via the name attribute, and form submission support. Use role="radio" only when you need custom styling or visual designs that cannot be achieved with native elements.

Live Demo: Radio Button Interactions

Color Selection

Choose your favorite color:

Selected: blue

Size Selection (Card Style)

Select a size:

Selected: medium

Try with keyboard: Focus a radio button, then use Arrow Keys to navigate between options. Selection follows focus automatically. Press Space to select the focused option if not auto-selected.

Code Examples

Basic Radio Group

<!-- Basic Radio Group with ARIA -->
<div role="radiogroup" aria-labelledby="color-label">
  <span id="color-label">Choose a color:</span>
  
  <div role="radio" aria-checked="false" tabindex="-1">
    Red
  </div>
  <div role="radio" aria-checked="true" tabindex="0">
    Blue
  </div>
  <div role="radio" aria-checked="false" tabindex="-1">
    Green
  </div>
</div>

<!-- Note: Only the checked radio (or first if none checked) 
     should have tabindex="0". All others should be tabindex="-1" -->

Native HTML (Preferred)

<!-- Native HTML Radio Buttons (Preferred) -->
<fieldset>
  <legend>Choose a color:</legend>
  
  <label>
    <input type="radio" name="color" value="red">
    Red
  </label>
  
  <label>
    <input type="radio" name="color" value="blue" checked>
    Blue
  </label>
  
  <label>
    <input type="radio" name="color" value="green">
    Green
  </label>
</fieldset>

<!-- Benefits of native radio inputs:
     - Built-in keyboard navigation
     - Automatic grouping via name attribute
     - Native form submission support
     - Works without JavaScript -->

Keyboard Navigation

<!-- Accessible Radio Group with Full Keyboard Support -->
<div 
  role="radiogroup" 
  aria-labelledby="size-label"
  id="size-group"
>
  <span id="size-label">Select size:</span>
  
  <div role="radio" aria-checked="true" tabindex="0" id="size-small">
    Small
  </div>
  <div role="radio" aria-checked="false" tabindex="-1" id="size-medium">
    Medium
  </div>
  <div role="radio" aria-checked="false" tabindex="-1" id="size-large">
    Large
  </div>
</div>

<script>
  const radioGroup = document.getElementById('size-group');
  const radios = radioGroup.querySelectorAll('[role="radio"]');
  let currentIndex = 0;

  radioGroup.addEventListener('keydown', (e) => {
    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        e.preventDefault();
        currentIndex = (currentIndex + 1) % radios.length;
        selectRadio(currentIndex);
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        e.preventDefault();
        currentIndex = (currentIndex - 1 + radios.length) % radios.length;
        selectRadio(currentIndex);
        break;
      case ' ':
        e.preventDefault();
        selectRadio(currentIndex);
        break;
    }
  });

  function selectRadio(index) {
    radios.forEach((radio, i) => {
      radio.setAttribute('aria-checked', i === index ? 'true' : 'false');
      radio.setAttribute('tabindex', i === index ? '0' : '-1');
    });
    radios[index].focus();
  }
</script>

Radio Buttons with Descriptions

<!-- Radio Buttons with Descriptions -->
<div role="radiogroup" aria-labelledby="plan-label">
  <h3 id="plan-label">Select a plan:</h3>
  
  <div 
    role="radio" 
    aria-checked="false" 
    aria-describedby="free-desc"
    tabindex="-1"
  >
    <span class="radio-label">Free</span>
    <span id="free-desc" class="radio-description">
      Basic features, up to 3 projects
    </span>
  </div>
  
  <div 
    role="radio" 
    aria-checked="true" 
    aria-describedby="pro-desc"
    tabindex="0"
  >
    <span class="radio-label">Pro</span>
    <span id="pro-desc" class="radio-description">
      All features, unlimited projects, priority support
    </span>
  </div>
  
  <div 
    role="radio" 
    aria-checked="false" 
    aria-describedby="enterprise-desc"
    tabindex="-1"
  >
    <span class="radio-label">Enterprise</span>
    <span id="enterprise-desc" class="radio-description">
      Custom solutions, dedicated account manager
    </span>
  </div>
</div>

Disabled Options

<!-- Radio Group with Disabled Options -->
<div role="radiogroup" aria-labelledby="shipping-label">
  <span id="shipping-label">Shipping method:</span>
  
  <div role="radio" aria-checked="true" tabindex="0">
    Standard (3-5 days)
  </div>
  
  <div role="radio" aria-checked="false" tabindex="-1">
    Express (1-2 days)
  </div>
  
  <div 
    role="radio" 
    aria-checked="false" 
    aria-disabled="true"
    tabindex="-1"
  >
    Same Day (unavailable in your area)
  </div>
</div>

<style>
  [aria-disabled="true"] {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>

<!-- Note: Disabled radios should still be announced 
     but not selectable via keyboard navigation -->

React Component

// React Radio Group Component
import { useState, useRef, useCallback } from 'react';

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

interface RadioGroupProps {
  name: string;
  label: string;
  options: RadioOption[];
  value: string;
  onChange: (value: string) => void;
}

function RadioGroup({ name, label, options, value, onChange }: RadioGroupProps) {
  const [focusedIndex, setFocusedIndex] = useState(
    options.findIndex(opt => opt.value === value) || 0
  );

  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    const enabledOptions = options.filter(opt => !opt.disabled);
    const currentEnabledIndex = enabledOptions.findIndex(
      opt => opt.value === options[focusedIndex].value
    );

    let newEnabledIndex = currentEnabledIndex;

    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        e.preventDefault();
        newEnabledIndex = (currentEnabledIndex + 1) % enabledOptions.length;
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        e.preventDefault();
        newEnabledIndex = (currentEnabledIndex - 1 + enabledOptions.length) % enabledOptions.length;
        break;
      case ' ':
        e.preventDefault();
        onChange(options[focusedIndex].value);
        return;
      default:
        return;
    }

    const newOption = enabledOptions[newEnabledIndex];
    const newIndex = options.findIndex(opt => opt.value === newOption.value);
    setFocusedIndex(newIndex);
    onChange(newOption.value);
  }, [focusedIndex, options, onChange]);

  return (
    <div
      role="radiogroup"
      aria-labelledby={`${name}-label`}
      onKeyDown={handleKeyDown}
    >
      <span id={`${name}-label`} className="label">{label}</span>
      
      {options.map((option, index) => {
        const isSelected = option.value === value;
        const isFocusable = index === focusedIndex;
        
        return (
          <div
            key={option.value}
            role="radio"
            aria-checked={isSelected}
            aria-disabled={option.disabled}
            aria-describedby={option.description ? `${name}-${option.value}-desc` : undefined}
            tabIndex={isFocusable ? 0 : -1}
            onClick={() => !option.disabled && onChange(option.value)}
            onFocus={() => setFocusedIndex(index)}
            className={`radio-option ${isSelected ? 'selected' : ''} ${option.disabled ? 'disabled' : ''}`}
          >
            <span className="radio-indicator" aria-hidden="true">
              {isSelected && <span className="radio-dot" />}
            </span>
            <span className="radio-content">
              <span className="radio-label">{option.label}</span>
              {option.description && (
                <span id={`${name}-${option.value}-desc`} className="radio-description">
                  {option.description}
                </span>
              )}
            </span>
          </div>
        );
      })}
    </div>
  );
}

// Usage
function App() {
  const [selectedPlan, setSelectedPlan] = useState('pro');

  return (
    <RadioGroup
      name="plan"
      label="Select a plan"
      value={selectedPlan}
      onChange={setSelectedPlan}
      options={[
        { value: 'free', label: 'Free', description: 'Basic features' },
        { value: 'pro', label: 'Pro', description: 'All features' },
        { value: 'enterprise', label: 'Enterprise', description: 'Custom solutions' },
      ]}
    />
  );
}

Card-Style Radio Buttons

<!-- Card-Style Radio Buttons (Visual Enhancement) -->
<div role="radiogroup" aria-labelledby="theme-label" class="card-radios">
  <h3 id="theme-label">Choose your theme:</h3>
  
  <div 
    role="radio" 
    aria-checked="false" 
    tabindex="-1"
    class="radio-card"
  >
    <div class="card-icon">☀️</div>
    <div class="card-title">Light</div>
    <div class="card-desc">Clean and bright</div>
  </div>
  
  <div 
    role="radio" 
    aria-checked="true" 
    tabindex="0"
    class="radio-card selected"
  >
    <div class="card-icon">🌙</div>
    <div class="card-title">Dark</div>
    <div class="card-desc">Easy on the eyes</div>
  </div>
  
  <div 
    role="radio" 
    aria-checked="false" 
    tabindex="-1"
    class="radio-card"
  >
    <div class="card-icon">🌈</div>
    <div class="card-title">System</div>
    <div class="card-desc">Match OS preference</div>
  </div>
</div>

<style>
  .card-radios {
    display: flex;
    gap: 1rem;
  }
  
  .radio-card {
    padding: 1.5rem;
    border: 2px solid #e5e7eb;
    border-radius: 1rem;
    cursor: pointer;
    transition: all 0.2s;
  }
  
  .radio-card.selected {
    border-color: #4f46e5;
    background: rgba(79, 70, 229, 0.1);
  }
  
  .radio-card:focus {
    outline: 2px solid #4f46e5;
    outline-offset: 2px;
  }
</style>

Keyboard Support

Radio buttons have a roving tabindex pattern—only one radio in the group is in the tab sequence at a time. Arrow keys move focus AND selection together.

TabMoves focus into/out of the radio group

Focus lands on the checked radio, or the first radio if none are checked.

Moves to and selects the next radio

If on the last radio, wraps to the first radio in the group.

Moves to and selects the previous radio

If on the first radio, wraps to the last radio in the group.

SpaceSelects the focused radio (if not auto-selected)

In most implementations, arrow keys auto-select, so Space is optional.

Best Practices

Use native <input type="radio"> elements when possible

Always wrap radio buttons in a radiogroup container

Provide a visible label for the entire group (fieldset/legend or aria-labelledby)

Use aria-checked to indicate the selection state (true/false)

Implement roving tabindex: only checked radio has tabindex="0"

Make arrow keys move focus AND selection together for expected behavior

×

Don't use radio for multiple-selection scenarios - use checkboxes instead

×

Don't allow users to deselect all radios - one must always be selected

×

Don't make every radio tabbable - use roving tabindex pattern

×

Don't use role="radio" without a radiogroup parent

Supported ARIA Attributes

aria-checkedRequired

Indicates whether the radio is checked (true/false)

aria-disabled

Indicates the radio is not available for interaction

aria-label

Provides an accessible name for the radio

aria-labelledby

References element(s) that label the radio

aria-describedby

References element(s) providing additional description

aria-posinset

Position of the radio in the group (for virtual lists)

aria-setsize

Total number of radios in the group (for virtual lists)

tabindex

Set to 0 for checked radio, -1 for others (roving tabindex)

Common Use Cases

Single-choice questions in surveys
Shipping/payment method selection
Plan/pricing tier selection
Size/variant selection in e-commerce
Gender/preference selection in forms
Theme selection (light/dark/system)
Sort order options
View mode selection (grid/list)
Rating scales (1-5 stars)
Date/time format preferences

Accessibility Notes

Roving Tabindex Pattern

Unlike checkboxes where each item is independently tabbable, radio groups use a roving tabindex pattern. Only one radio (the checked one, or the first if none are checked) is in the tab sequence. This makes it faster to tab through forms while arrow keys provide navigation within the group.

Visual Design

Provide clear visual distinction between checked and unchecked states. The classic radio button (empty circle vs. filled dot) is universally recognized. When using card-style radios, include a visible indicator in addition to background/border changes. Ensure 3:1 minimum contrast ratio for the radio indicator.

Screen Reader Announcements

Screen readers announce radio buttons as "[label], radio button, [checked/not checked], [position] of [total]". They also announce the group label when entering the radio group. Ensure each radio has a clear, descriptive label and the group has a meaningful overall label.

Required Selection

If the radio group is required, use aria-required="true" on the radiogroup element. Consider pre-selecting a default option when appropriate to ensure users don't accidentally submit a form without making a selection.

Related Roles & Attributes

Specifications & Resources