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.
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
Selected: blue
Size Selection (Card Style)
Best for compact spaces
Standard size for most uses
Great for emphasis
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.
Focus lands on the checked radio, or the first radio if none are checked.
If on the last radio, wraps to the first radio in the group.
If on the first radio, wraps to the last radio in the group.
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-checkedRequiredIndicates whether the radio is checked (true/false)
aria-disabledIndicates the radio is not available for interaction
aria-labelProvides an accessible name for the radio
aria-labelledbyReferences element(s) that label the radio
aria-describedbyReferences element(s) providing additional description
aria-posinsetPosition of the radio in the group (for virtual lists)
aria-setsizeTotal number of radios in the group (for virtual lists)
tabindexSet to 0 for checked radio, -1 for others (roving tabindex)
Common Use Cases
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.

