Radio Group
A set of checkable buttons where only one can be checked at a time.
🤖 AI Implementation GuideDemo
Basic Radio Group
Use arrow keys to navigate and select. Tab moves focus in/out of the group.
With Default Value
Pre-selected option using the defaultValue prop.
With Disabled Option
Disabled options are skipped during keyboard navigation.
Horizontal Orientation
Horizontal layout with orientation="horizontal".
Native HTML
Use Native HTML First
Before using this custom component, consider using native <input type="radio"> elements with <fieldset> and <legend>.
They provide built-in accessibility, work without JavaScript, and require no ARIA attributes.
<fieldset>
<legend>Favorite color</legend>
<label><input type="radio" name="color" value="red" /> Red</label>
<label><input type="radio" name="color" value="blue" /> Blue</label>
<label><input type="radio" name="color" value="green" /> Green</label>
</fieldset> Use custom implementations when you need consistent cross-browser keyboard behavior or custom styling that native elements cannot provide.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic form input | Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Arrow key navigation | Browser-dependent* | Consistent behavior |
| Custom styling | Limited (browser-dependent) | Full control |
| Form submission | Built-in | Requires hidden input |
*Native radio keyboard behavior varies between browsers. Some browsers may not support all APG keyboard interactions (like Home/End) out of the box.
Accessibility Features
WAI-ARIA Roles
| Role | Element | Description |
|---|---|---|
radiogroup | Container element |
Groups radio buttons together. Must have an accessible name via aria-label or
aria-labelledby.
|
radio | Each option element | Identifies the element as a radio button. Only one radio in a group can be checked at a time. |
This implementation uses custom role="radiogroup" and role="radio" for consistent
cross-browser keyboard behavior. Native <input type="radio"> provides these roles
implicitly.
WAI-ARIA States
aria-checked
Indicates the current checked state of the radio button. Only one radio in a group should have
aria-checked="true".
| Values | true | false |
| Required | Yes (on each radio) |
| Change Trigger | Click, Space, Arrow keys |
aria-disabled
Indicates that the radio button is not interactive and cannot be selected.
| Values | true (only when disabled) |
| Required | No (only when disabled) |
| Effect | Skipped during arrow key navigation, cannot be selected |
WAI-ARIA Properties
aria-orientation
Indicates the orientation of the radio group. Vertical is the default.
| Values | horizontal | vertical (default) |
| Required | No (only set when horizontal) |
| Note | This implementation supports all arrow keys regardless of orientation |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into the group (to selected or first radio) |
| Shift + Tab | Move focus out of the group |
| Space | Select the focused radio (does not unselect) |
| Arrow Down / Right | Move to next radio and select (wraps to first) |
| Arrow Up / Left | Move to previous radio and select (wraps to last) |
| Home | Move to first radio and select |
| End | Move to last radio and select |
Note: Unlike Checkbox, arrow keys both move focus AND change selection. Disabled radios are skipped during navigation.
Focus Management (Roving Tabindex)
Radio groups use roving tabindex to manage focus. Only one radio in the group is tabbable at any time:
- Selected radio has
tabindex="0" - If none selected, first enabled radio has
tabindex="0" - All other radios have
tabindex="-1" - Disabled radios always have
tabindex="-1"
Accessible Naming
Both the radio group and individual radios must have accessible names:
- Radio group - Use
aria-labeloraria-labelledbyon the container - Individual radios - Each radio is labeled by its visible text content via
aria-labelledby - Native alternative - Use
<fieldset>with<legend>for group labeling
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Filled circle - Indicates selected state
- Empty circle - Indicates unselected state
- Reduced opacity - Indicates disabled state
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
References
Source Code
<template>
<div
role="radiogroup"
:aria-label="ariaLabel"
:aria-labelledby="ariaLabelledby"
:aria-orientation="orientation === 'horizontal' ? 'horizontal' : undefined"
:class="['apg-radio-group', props.class]"
>
<!-- Hidden input for form submission -->
<input type="hidden" :name="name" :value="selectedValue" />
<div
v-for="option in options"
:key="option.id"
:ref="(el) => setRadioRef(option.value, el as HTMLDivElement | null)"
role="radio"
:aria-checked="selectedValue === option.value"
:aria-disabled="option.disabled || undefined"
:aria-labelledby="`${instanceId}-label-${option.id}`"
:tabindex="getTabIndex(option)"
:class="[
'apg-radio',
selectedValue === option.value && 'apg-radio--selected',
option.disabled && 'apg-radio--disabled',
]"
@click="handleClick(option)"
@keydown="(e) => handleKeyDown(e, option.value)"
>
<span class="apg-radio-control" aria-hidden="true">
<span class="apg-radio-indicator" />
</span>
<span :id="`${instanceId}-label-${option.id}`" class="apg-radio-label">
{{ option.label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export interface RadioOption {
id: string;
label: string;
value: string;
disabled?: boolean;
}
export interface RadioGroupProps {
/** Radio options */
options: RadioOption[];
/** Group name for form submission */
name: string;
/** Accessible label for the group */
ariaLabel?: string;
/** Reference to external label */
ariaLabelledby?: string;
/** Controlled value (for v-model) */
modelValue?: string;
/** Initially selected value (uncontrolled) */
defaultValue?: string;
/** Orientation of the group */
orientation?: 'horizontal' | 'vertical';
/** Additional CSS class */
class?: string;
}
const props = withDefaults(defineProps<RadioGroupProps>(), {
ariaLabel: undefined,
ariaLabelledby: undefined,
modelValue: undefined,
defaultValue: '',
orientation: 'vertical',
class: undefined,
});
const emit = defineEmits<{
'update:modelValue': [value: string];
valueChange: [value: string];
}>();
// Generate unique ID for this instance
const instanceId = `radio-group-${Math.random().toString(36).slice(2, 9)}`;
// Filter enabled options
const enabledOptions = computed(() => props.options.filter((opt) => !opt.disabled));
// Check if controlled mode (v-model provided)
const isControlled = computed(() => props.modelValue !== undefined);
// Find initial value
const getInitialValue = () => {
// If controlled, use modelValue
if (props.modelValue !== undefined) {
const option = props.options.find((opt) => opt.value === props.modelValue);
if (option && !option.disabled) {
return props.modelValue;
}
}
// Otherwise use defaultValue
if (props.defaultValue) {
const option = props.options.find((opt) => opt.value === props.defaultValue);
if (option && !option.disabled) {
return props.defaultValue;
}
}
return '';
};
const internalValue = ref(getInitialValue());
// Computed value that respects controlled/uncontrolled mode
const selectedValue = computed(() => {
if (isControlled.value) {
return props.modelValue ?? '';
}
return internalValue.value;
});
// Watch for external modelValue changes in controlled mode
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== undefined) {
internalValue.value = newValue;
}
}
);
// Refs for focus management
const radioRefs = new Map<string, HTMLDivElement>();
const setRadioRef = (value: string, el: HTMLDivElement | null) => {
if (el) {
radioRefs.set(value, el);
} else {
radioRefs.delete(value);
}
};
// Get the tabbable radio value
const getTabbableValue = () => {
if (
selectedValue.value &&
enabledOptions.value.some((opt) => opt.value === selectedValue.value)
) {
return selectedValue.value;
}
return enabledOptions.value[0]?.value || '';
};
const getTabIndex = (option: RadioOption): number => {
if (option.disabled) return -1;
return option.value === getTabbableValue() ? 0 : -1;
};
// Focus a radio by value
const focusRadio = (value: string) => {
const radioEl = radioRefs.get(value);
radioEl?.focus();
};
// Select a radio
const selectRadio = (value: string) => {
const option = props.options.find((opt) => opt.value === value);
if (option && !option.disabled) {
internalValue.value = value;
emit('update:modelValue', value);
emit('valueChange', value);
}
};
// Get enabled index of a value
const getEnabledIndex = (value: string) => {
return enabledOptions.value.findIndex((opt) => opt.value === value);
};
// Navigate and select
const navigateAndSelect = (direction: 'next' | 'prev' | 'first' | 'last', currentValue: string) => {
if (enabledOptions.value.length === 0) return;
let targetIndex: number;
const currentIndex = getEnabledIndex(currentValue);
switch (direction) {
case 'next':
targetIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledOptions.value.length : 0;
break;
case 'prev':
targetIndex =
currentIndex >= 0
? (currentIndex - 1 + enabledOptions.value.length) % enabledOptions.value.length
: enabledOptions.value.length - 1;
break;
case 'first':
targetIndex = 0;
break;
case 'last':
targetIndex = enabledOptions.value.length - 1;
break;
}
const targetOption = enabledOptions.value[targetIndex];
if (targetOption) {
focusRadio(targetOption.value);
selectRadio(targetOption.value);
}
};
const handleKeyDown = (event: KeyboardEvent, optionValue: string) => {
const { key } = event;
switch (key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
navigateAndSelect('next', optionValue);
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
navigateAndSelect('prev', optionValue);
break;
case 'Home':
event.preventDefault();
navigateAndSelect('first', optionValue);
break;
case 'End':
event.preventDefault();
navigateAndSelect('last', optionValue);
break;
case ' ':
event.preventDefault();
selectRadio(optionValue);
break;
}
};
const handleClick = (option: RadioOption) => {
if (!option.disabled) {
focusRadio(option.value);
selectRadio(option.value);
}
};
</script> Usage
<script setup>
import { RadioGroup } from './RadioGroup.vue';
const options = [
{ id: 'red', label: 'Red', value: 'red' },
{ id: 'blue', label: 'Blue', value: 'blue' },
{ id: 'green', label: 'Green', value: 'green' },
];
const handleChange = (value) => {
console.log('Selected:', value);
};
</script>
<template>
<RadioGroup
:options="options"
name="color"
aria-label="Favorite color"
default-value="blue"
@value-change="handleChange"
/>
</template> API
RadioGroupProps
| Prop | Type | Default | Description |
|---|---|---|---|
options | RadioOption[] | required | Array of radio options |
name | string | required | Group name for form submission |
aria-label | string | - | Accessible label for the group |
aria-labelledby | string | - | ID of labeling element |
default-value | string | "" | Initially selected value |
orientation | 'horizontal' | 'vertical' | 'vertical' | Layout orientation |
class | string | - | Additional CSS class |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | Emitted for v-model binding |
valueChange | string | Emitted when selection changes |
RadioOption
interface RadioOption {
id: string;
label: string;
value: string;
disabled?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, focus management, and accessibility requirements.
Test Categories
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="radiogroup" | Container has radiogroup role |
role="radio" | Each option has radio role |
aria-checked | Selected radio has aria-checked="true" |
aria-disabled | Disabled radios have aria-disabled="true" |
aria-orientation | Only set when horizontal (vertical is default) |
accessible name | Group and radios have accessible names |
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Tab focus | Tab focuses selected radio (or first if none) |
Tab exit | Tab/Shift+Tab exits the group |
Space select | Space selects focused radio |
Space no unselect | Space does not unselect already selected radio |
ArrowDown/Right | Moves to next and selects |
ArrowUp/Left | Moves to previous and selects |
Home | Moves to first and selects |
End | Moves to last and selects |
Arrow wrap | Wraps from last to first and vice versa |
Disabled skip | Disabled radios skipped during navigation |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabindex="0" | Selected radio has tabindex="0" |
tabindex="-1" | Non-selected radios have tabindex="-1" |
Disabled tabindex | Disabled radios have tabindex="-1" |
First tabbable | First enabled radio tabbable when none selected |
Single tabbable | Only one tabindex="0" in group at any time |
Medium Priority: Form Integration
| Test | Description |
|---|---|
hidden input | Hidden input exists for form submission |
name attribute | Hidden input has correct name |
value sync | Hidden input value reflects selection |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
selected axe | No violations with selected value |
disabled axe | No violations with disabled option |
Low Priority: Props & Behavior
| Test | Description |
|---|---|
onValueChange | Callback fires on selection change |
defaultValue | Initial selection from defaultValue |
className | Custom class applied to container |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
Resources
- WAI-ARIA APG: Radio Group Pattern (opens in new tab)
- MDN: <input type="radio"> (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist