Radio
A radio button and a group wrapper for mutually exclusive selections.
sg-radio— standalone radio button for a single boolean choice within a named group.sg-radio-group—<fieldset>wrapper that manages a set of radios, propagatescolor,size,name, anddisabledto all children, and handles roving keyboard navigation.
Features
2 Orientations (group) — vertical & horizontal Accessible — ARIA roles, roving tabindex, arrow key nav 6 Semantic Colors — primary, secondary, info, success, warning, error States — checked, unchecked, disabled 3 Sizes — sm, md, lg Helper & Error Text (group) — inline validation feedback
Source Code
View Radio Source
import { define, useField, html, inject, prop } from '@vielzeug/craft';
import { computed } from '@vielzeug/ripple';
import type { CheckableProps, ComponentSize, ThemeColor } from '../../types';
import { type CheckableChangePayload, lifecycleSignal, createCheckable, createListControl } from '../../headless';
import { CONTROL_SIZE_PRESET, disablableBundle, sizableBundle, themableBundle } from '../../shared';
import {
coarsePointerMixin,
colorThemeMixin,
disabledStateMixin,
forcedColorsFormControlMixin,
sizeVariantMixin,
} from '../../styles';
import { RADIO_GROUP_CTX } from '../radio-group/radio-group';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import { renderHelperRegion } from '../shared/templates';
import componentStyles from './radio.css?inline';
/** Radio component properties */
export type SgRadioEvents = {
change: CheckableChangePayload;
};
export type SgRadioProps = CheckableProps & {
/** Theme color */
color?: ThemeColor;
/** Disable interaction */
disabled?: boolean;
/** Error message (marks field as invalid) */
error?: string;
/** Helper text displayed below the radio */
helper?: string;
/** Component size */
size?: ComponentSize;
};
/**
* A customizable radio button component for mutually exclusive selections.
*
* @element sg-radio
*
* @attr {boolean} checked - Checked state
* @attr {boolean} disabled - Disable radio interaction
* @attr {string} value - Field value (required for radio groups)
* @attr {string} name - Form field name (required for radio groups)
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} size - Radio size: 'sm' | 'md' | 'lg'
* @attr {string} error - Error message (marks field as invalid)
* @attr {string} helper - Helper text displayed below the radio
*
* @fires change - Emitted when radio is selected. detail: { checked: boolean, value: string, originalEvent?: Event }
*
* @slot - Radio button label text
*
* @cssprop --radio-size - Control size (width and height)
* @cssprop --radio-bg - Unchecked background color
* @cssprop --radio-border-color - Unchecked border color
* @cssprop --radio-checked-bg - Selected indicator background color
* @cssprop --radio-color - Selected indicator dot color
* @cssprop --radio-font-size - Label font size
* @part radio - The radio wrapper element
* @part circle - The visual radio circle
* @part label - The label element
* @part helper-text - The helper/error text element
*
* @example
* ```html
* <sg-radio name="plan" value="free">Free</sg-radio>
* <sg-radio name="plan" value="pro" checked color="primary">Pro</sg-radio>
* <sg-radio name="plan" value="enterprise" disabled>Enterprise</sg-radio>
* ```
*/
export const RADIO_TAG = 'sg-radio' as const;
define<SgRadioProps, SgRadioEvents>(RADIO_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
checked: prop.bool(false),
error: prop.string(),
helper: prop.string(),
name: prop.string(),
value: prop.string(),
},
setup(props, { bind, el, emit, onCleanup }) {
const groupCtx = inject(RADIO_GROUP_CTX);
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
const effectiveName = computed(() => groupCtx?.name.value || props.name.value || '');
const effectiveSize = computed(() => groupCtx?.size.value ?? fCtxProps.size.value);
const effectiveColor = computed(() => groupCtx?.color.value ?? props.color.value);
const checkedFromState = computed(() => {
if (groupCtx) return groupCtx.value.value === props.value.value;
return Boolean(props.checked.value);
});
const getRadioGroup = (): HTMLElement[] => {
const radioName = effectiveName.value;
if (!radioName) return [];
return Array.from(document.querySelectorAll<HTMLElement>(`sg-radio[name="${radioName}"]`)).filter(
(r) => !r.hasAttribute('disabled'),
);
};
const selectRadio = (radio: HTMLElement, originalEvent?: Event): void => {
if (groupCtx) {
groupCtx.select(radio.getAttribute('value') ?? '', originalEvent);
return;
}
radio.click();
};
const listControl = createListControl({
getItems: () => getRadioGroup(),
keys: { next: ['ArrowDown', 'ArrowRight'], prev: ['ArrowUp', 'ArrowLeft'] },
loop: true,
onNavigate: (_action, index, event) => {
const nextRadio = getRadioGroup()[index];
if (!nextRadio) return;
nextRadio.focus();
selectRadio(nextRadio, event);
},
});
const activateSelf = (originalEvent?: Event): void => {
if (checkable.checked.value) return;
if (groupCtx) {
groupCtx.select(props.value.value ?? '', originalEvent);
return;
}
checkable.toggle(originalEvent ?? new Event('change'));
};
let _formField: { reportValidity(): void } | null = null;
const checkable = createCheckable({
checked: checkedFromState,
disabled: computed(() => fCtxProps.disabled.value || Boolean(groupCtx?.disabled.value)),
error: props.error,
getFormField: () => _formField,
helper: props.helper,
onToggle: (payload) => {
emit('change', payload);
},
prefix: 'radio',
signal: lifecycleSignal(onCleanup),
validateOn: formCtx?.validateOn,
value: props.value,
});
_formField = useField<string | null>({
disabled: checkable.disabled,
toFormValue: (v) => v,
value: checkable.checkableFormValue,
});
const { assistiveId, checked, disabled, errorText, helperText, labelId, toggle } = checkable;
bind({
attr: {
ariaChecked: () => (checked.value ? 'true' : 'false'),
ariaDescribedby: () => (errorText.value || helperText.value ? assistiveId : null),
ariaDisabled: () => (disabled.value ? 'true' : null),
ariaInvalid: () => (errorText.value ? 'true' : null),
ariaLabelledby: labelId,
checked,
color: effectiveColor,
disabled: () => (disabled.value ? true : undefined),
name: () => effectiveName.value || undefined,
role: 'radio',
size: effectiveSize,
tabindex: () => {
if (disabled.value) return undefined;
return checked.value ? 0 : -1;
},
},
class: () => ({
'is-checked': checked.value,
'is-disabled': disabled.value,
}),
on: {
click: (e: MouseEvent) => {
if (disabled.value) return;
if (groupCtx) {
groupCtx.select(props.value.value ?? '', e);
} else {
if (!effectiveName.value) return;
if (!checked.value) {
const radioName = props.name.value;
const allRadios = document.querySelectorAll<HTMLElement>(`sg-radio[name="${radioName}"]`);
allRadios.forEach((radio) => {
if (radio !== el) radio.removeAttribute('checked');
});
toggle(e);
}
}
},
keydown: (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
activateSelf(e);
return;
}
const radios = getRadioGroup();
if (radios.length === 0) return;
const selfIndex = radios.indexOf(el);
if (selfIndex === -1) return;
listControl.set(selfIndex);
listControl.handleKeydown(e);
},
},
});
return html`
<div class="radio-wrapper" part="radio">
<div class="circle" part="circle">
<div class="dot" part="dot"></div>
</div>
</div>
<span class="label" part="label" id="${labelId}"><slot></slot></span>
${renderHelperRegion(assistiveId, errorText, helperText)}
`;
},
styles: [
colorThemeMixin,
forcedColorsFormControlMixin,
disabledStateMixin,
coarsePointerMixin,
sizeVariantMixin(CONTROL_SIZE_PRESET),
componentStyles,
],
});View Radio Group Source
import { createContext, createStableId, define, useField, html, inject, prop, when } from '@vielzeug/craft';
import { type ReadonlySignal } from '@vielzeug/ripple';
import type { ComponentSize, ThemeColor } from '../../types';
import {
type ChoiceChangeDetail,
lifecycleSignal,
createChoiceField,
createListControl,
getChoiceLabel,
getLightChildrenByTag,
} from '../../headless';
import { disablableBundle, sizableBundle, themableBundle } from '../../shared';
import { colorThemeMixin, disabledStateMixin, sizeVariantMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './radio-group.css?inline';
/** Radio group component properties */
export type SgRadioGroupProps = {
/** Theme color tint */
color?: ThemeColor;
/** Disabled state */
disabled?: boolean;
/** Error message text */
error?: string;
/** Helper text displayed below the items */
helper?: string;
/** Group label text */
label?: string;
/** Form field name */
name?: string;
/** Layout orientation */
orientation?: 'horizontal' | 'vertical';
/** Required field */
required?: boolean;
/** Items size preset */
size?: ComponentSize;
/** Initial selected value */
value?: string;
};
export type RadioGroupContext = {
color: ReadonlySignal<ThemeColor | undefined>;
disabled: ReadonlySignal<boolean>;
name: ReadonlySignal<string | undefined>;
select: (value: string, originalEvent?: Event) => void;
size: ReadonlySignal<ComponentSize | undefined>;
value: ReadonlySignal<string | undefined>;
};
export const RADIO_GROUP_CTX = createContext<RadioGroupContext | undefined>('SgRadioGroup');
/** Events emitted by the radio-group component */
export type SgRadioGroupEvents = {
/** Emitted when the selection changes */
change: ChoiceChangeDetail;
};
/**
* A group of radio buttons that allows users to select a single option from a set.
* Supports keyboard navigation (arrows) and automatic value management.
*
* @element sg-radio-group
*
* @attr {string} value - Selected value
* @attr {string} name - Form field name
* @attr {string} label - Group label
* @attr {string} orientation - Layout: 'vertical' | 'horizontal'
* @attr {boolean} required - Required field
*
* @fires change - Emitted when a radio is selected. detail: { values: string[], labels: string[], originalEvent?: Event }
*
* @slot - Place `sg-radio` elements here
*
* @cssprop --radio-group-direction - Flex direction of the items list ('row' | 'column')
* @cssprop --radio-group-gap - Gap between radio items
* @part items - Items container.
* @example
* ```html
* <sg-radio-group name="plan" label="Choose a plan" value="free" required>
* <sg-radio value="free">Free</sg-radio>
* <sg-radio value="pro">Pro</sg-radio>
* <sg-radio value="enterprise">Enterprise</sg-radio>
* </sg-radio-group>
* <sg-radio-group name="direction" orientation="horizontal" color="primary">
* <sg-radio value="left">Left</sg-radio>
* <sg-radio value="right">Right</sg-radio>
* </sg-radio-group>
* ```
*/
export const RADIO_GROUP_TAG = 'sg-radio-group' as const;
define<SgRadioGroupProps, SgRadioGroupEvents>(RADIO_GROUP_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
error: prop.string(),
helper: prop.string(),
label: prop.string(),
name: prop.string(),
orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'vertical'),
required: prop.bool(false),
value: prop.string(),
},
setup(props, { bind, el, emit, onCleanup, provide, slots, watch }) {
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
let _formField: { reportValidity(): void } | null = null;
const choice = createChoiceField({
disabled: fCtxProps.disabled,
error: props.error,
getFormField: () => _formField,
helper: props.helper,
label: props.label,
prefix: 'radio-group',
signal: lifecycleSignal(onCleanup),
validateOn: formCtx?.validateOn,
value: props.value,
});
_formField = useField<string>({ disabled: choice.disabled, toFormValue: (v) => v, value: choice.formValue });
const selectedValue = choice.selectedValue;
const isDisabled = fCtxProps.disabled;
bind({
attr: {
size: fCtxProps.size,
value: () => selectedValue.value || null,
},
});
const getSlottedRadios = (): HTMLElement[] => getLightChildrenByTag(el, 'sg-radio');
const getEnabledRadios = (): HTMLElement[] =>
isDisabled.value ? [] : getSlottedRadios().filter((radio) => !radio.hasAttribute('disabled'));
const getLabelForValue = (value: string): string => getChoiceLabel(getSlottedRadios(), value);
const selectRadio = (val: string, originalEvent?: Event): void => {
choice.setValues(val ? [val] : []);
const labels = val ? [getLabelForValue(val)] : [];
const values = val ? [val] : [];
emit('change', { labels, originalEvent, values });
choice.triggerValidation('blur');
};
provide(RADIO_GROUP_CTX, {
color: props.color as ReadonlySignal<ThemeColor | undefined>,
disabled: isDisabled,
name: props.name,
select: selectRadio,
size: props.size as ReadonlySignal<ComponentSize | undefined>,
value: selectedValue,
});
// Sync name/color/size/disabled/checked onto slotted sg-radio children.
watch(() => {
void slots.elements().value;
void selectedValue.value;
const radios = getSlottedRadios();
for (const radio of radios) {
const val = radio.getAttribute('value') ?? '';
radio.toggleAttribute('checked', val === selectedValue.value);
if (props.name.value) radio.setAttribute('name', props.name.value);
else radio.removeAttribute('name');
if (props.color.value) radio.setAttribute('color', props.color.value);
else radio.removeAttribute('color');
if (props.size.value) radio.setAttribute('size', props.size.value);
else radio.removeAttribute('size');
radio.toggleAttribute('disabled', isDisabled.value);
}
});
// Roving tabindex: only the selected (or first) radio is tabbable.
watch(() => {
void slots.elements().value;
const radios = getSlottedRadios();
let hasFocusable = false;
for (const radio of radios) {
const isSelected = radio.getAttribute('value') === selectedValue.value;
radio.setAttribute('tabindex', isSelected && !isDisabled.value ? '0' : '-1');
if (isSelected && !isDisabled.value) hasFocusable = true;
}
if (!hasFocusable && radios.length > 0) {
const first = radios.find((r) => !r.hasAttribute('disabled'));
if (first) first.setAttribute('tabindex', '0');
}
});
const listControl = createListControl<HTMLElement>({
getItems: getEnabledRadios,
keys: { next: ['ArrowDown', 'ArrowRight'], prev: ['ArrowUp', 'ArrowLeft'] },
loop: true,
onNavigate: (_action, index, event) => {
const radio = getEnabledRadios()[index];
if (!radio) return;
radio.focus();
if (radio.tagName === 'SG-RADIO') {
selectRadio(radio.getAttribute('value') ?? '', event);
}
},
});
bind({
on: {
change: (e: Event) => {
if (e.target === el) return;
e.stopPropagation();
selectRadio((e.target as HTMLElement).getAttribute('value') ?? '', e);
},
keydown: (e: KeyboardEvent) => {
const radios = getEnabledRadios();
if (!radios.length) return;
const focused = radios.indexOf(document.activeElement as HTMLElement);
if (focused === -1) return;
listControl.set(focused);
listControl.handleKeydown(e);
},
},
});
const legendId = createStableId('radio-group-legend');
return html`
<fieldset
role="radiogroup"
aria-required="${() => String(Boolean(props.required.value))}"
:aria-invalid="${choice.ariaInvalid}"
:aria-errormessage="${choice.ariaErrorMessage}"
:aria-describedby="${choice.ariaDescribedBy}">
<legend id="${legendId}" ?hidden=${() => !props.label.value}>
${props.label}${when(
() => Boolean(props.required.value),
() => html`<span aria-hidden="true"> *</span>`,
)}
</legend>
<div class="radio-group-items" part="items">
<slot></slot>
</div>
<div
class="helper-text"
part="helper"
id="${choice.assistiveId}"
:role="${() => (choice.errorText.value ? 'alert' : null)}"
aria-live="polite"
?hidden="${() => !choice.errorText.value && !choice.helperText.value}">
${() => choice.errorText.value || choice.helperText.value}
</div>
</fieldset>
`;
},
styles: [colorThemeMixin, sizeVariantMixin(), disabledStateMixin, componentStyles],
});Standalone Radio
Basic Usage
<sg-radio name="choice" value="option1" checked>Option 1</sg-radio>
<sg-radio name="choice" value="option2">Option 2</sg-radio>Radio Groups
Radio buttons with the same name attribute form a group where only one can be selected at a time. The name attribute is required for proper radio button behavior.
Colors
Six semantic colors for different contexts. Defaults to neutral when no color is specified.
Sizes
Three sizes for different contexts.
Disabled
Prevent interaction and reduce opacity for unavailable options.
Radio Group
sg-radio-group wraps sg-radio elements in a semantic <fieldset>. Set value to the default selected option and name to share the field name across all children.
Basic Usage
<sg-radio-group name="size" label="T-shirt size" value="medium">
<sg-radio value="small">Small</sg-radio>
<sg-radio value="medium">Medium</sg-radio>
<sg-radio value="large">Large</sg-radio>
</sg-radio-group>Orientation
Colors & Sizes
Color and size set on the group are automatically propagated to all child radios.
Helper & Error Text
Disabled
In a Form
The selected value attribute is submitted with the form under the name field name.
<form id="survey">
<sg-radio-group name="experience" label="How would you rate your experience?" required>
<sg-radio value="1">Poor</sg-radio>
<sg-radio value="2">Fair</sg-radio>
<sg-radio value="3">Good</sg-radio>
<sg-radio value="4">Excellent</sg-radio>
</sg-radio-group>
<sg-button type="submit">Submit</sg-button>
</form>
<script type="module">
import '@vielzeug/sigil/radio-group';
import '@vielzeug/sigil/radio';
import '@vielzeug/sigil/button';
document.getElementById('survey').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log('Experience rating:', data.get('experience'));
});
</script>API Reference
sg-radio Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
checked | boolean | false | Radio button checked state |
disabled | boolean | false | Disable the radio button |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | 'primary' | Semantic color |
size | 'sm' | 'md' | 'lg' | 'md' | Radio button size |
name | string | — | Form field name (required for grouping) |
value | string | — | Form field value when checked |
sg-radio Slots
| Slot | Description |
|---|---|
| (default) | Radio button label content |
sg-radio Events
| Event | Detail | Description |
|---|---|---|
change | { checked: boolean, value: string, originalEvent: Event } | Emitted when checked state changes (only when becoming checked) |
sg-radio CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--radio-size | Control size (width and height) | Size-dependent |
--radio-bg | Unchecked background color | Theme-dependent |
--radio-border-color | Unchecked border color | Theme-dependent |
--radio-checked-bg | Selected indicator background color | Color-dependent |
--radio-color | Selected indicator dot color | Theme-dependent |
--radio-font-size | Label font size | Size-dependent |
sg-radio-group Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
label | string | '' | Legend text — required for accessibility |
value | string | '' | Currently selected value |
name | string | '' | Form field name — propagated to all child radios |
orientation | 'vertical' | 'horizontal' | 'vertical' | Layout direction |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Color theme — propagated to all child radios |
size | 'sm' | 'md' | 'lg' | — | Size — propagated to all child radios |
disabled | boolean | false | Disable all radios in the group |
error | string | '' | Error message shown below the group |
helper | string | '' | Helper text (hidden when error is set) |
required | boolean | false | Mark the group as required |
sg-radio-group Slots
| Slot | Description |
|---|---|
| (default) | Place sg-radio elements here |
sg-radio-group Events
| Event | Detail | Description |
|---|---|---|
change | { value: string } | Emitted when a radio is selected |
sg-radio-group CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--radio-group-gap | Spacing between options | var(--size-2) |
--radio-group-direction | Flex direction (column/row) | column |
Accessibility
The radio components follow WCAG 2.1 Level AA standards.
sg-radio
Space/Enterselect a radio;Tabmoves focus in and out of the group.- Arrow keys navigate between radios within a group using a roving tabindex.
- Uses
role="radio"witharia-checkedreflecting the current state. aria-disabledreflects the disabled state.
sg-radio-group
- Renders as a
<fieldset>with a<legend>for thelabelattribute.
aria-requiredandaria-invalidreflect the validation state;aria-errormessageandaria-describedbylink the text nodes.
Best Practices
- Always provide a meaningful
labelon the group — it is read before each option by screen readers. - Always use the
nameattribute (or set it once on the group) so radios are mutually exclusive. - Provide a default
valuewhen a sensible default exists. - For non-mutually exclusive choices, use
sg-checkbox-groupinstead.