Radio
A radio button and a group wrapper for mutually exclusive selections.
bit-radio— standalone radio button for a single boolean choice within a named group.bit-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 { computed, defineComponent, defineField, html, inject, signal, watch } from '@vielzeug/craftit';
import { useA11yControl, createCheckableControl } from '@vielzeug/craftit/labs';
import type { CheckableProps, DisablableProps, SizableProps, ThemableProps } from '../../types';
import { coarsePointerMixin, formControlMixins, sizeVariantMixin } from '../../styles';
import { RADIO_GROUP_CTX } from '../radio-group/radio-group';
import { CONTROL_SIZE_PRESET } from '../shared/design-presets';
import { mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import componentStyles from './radio.css?inline';
/** Radio component properties */
export type BitRadioEvents = {
change: { checked: boolean; fieldValue: string; originalEvent?: Event; value: boolean };
};
export type BitRadioProps = CheckableProps &
ThemableProps &
SizableProps &
DisablableProps & {
/** Error message (marks field as invalid) */
error?: string;
/** Helper text displayed below the radio */
helper?: string;
};
/**
* A customizable radio button component for mutually exclusive selections.
*
* @element bit-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: { value: boolean, checked: boolean, fieldValue: string, originalEvent?: Event }
*
* @slot - Radio button label text
*
* @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
*/
export const RADIO_TAG = defineComponent<BitRadioProps, BitRadioEvents>({
formAssociated: true,
props: {
checked: { default: false },
color: { default: undefined },
disabled: { default: false },
error: { default: '' },
helper: { default: '' },
name: { default: '' },
size: { default: undefined },
value: { default: '' },
},
setup({ emit, host, props, reflect }) {
const groupCtx = inject(RADIO_GROUP_CTX, undefined);
const formCtx = inject(FORM_CTX, undefined);
const effectiveName = computed(() => groupCtx?.name.value || props.name.value || '');
const effectiveSize = computed(() => groupCtx?.size.value ?? props.size.value);
const effectiveColor = computed(() => groupCtx?.color.value ?? props.color.value);
const effectiveDisabled = computed(() => Boolean(groupCtx?.disabled.value || props.disabled.value));
mountFormContextSync(host, formCtx, props);
// Local signal — source of truth for checked state.
// Driven by group context when inside a radio-group, otherwise by the checked prop.
const checkedSignal = signal(Boolean(props.checked.value));
if (groupCtx) {
watch(
computed(() => groupCtx.value.value === props.value.value),
(isChecked) => {
checkedSignal.value = isChecked;
},
{ immediate: true },
);
} else {
watch(
props.checked,
(v) => {
checkedSignal.value = Boolean(v);
},
{ immediate: true },
);
}
const control = createCheckableControl({
checked: checkedSignal,
disabled: props.disabled,
onToggle: (e) => {
emit('change', {
checked: control.checked.value,
fieldValue: props.value.value ?? '',
originalEvent: e,
value: control.checked.value,
});
},
value: props.value,
});
defineField(
{
disabled: effectiveDisabled,
toFormValue: (v: string | null) => v,
value: computed(() => (checkedSignal.value ? (props.value.value ?? '') : null)),
},
{
onReset: () => {
checkedSignal.value = Boolean(props.checked.value);
},
},
);
const getRadioGroup = (): HTMLElement[] => {
const radioName = effectiveName.value;
if (!radioName) return [];
return Array.from(document.querySelectorAll<HTMLElement>(`bit-radio[name="${radioName}"]`)).filter(
(r) => !r.hasAttribute('disabled'),
);
};
const a11y = useA11yControl(host, {
checked: () => (control.checked.value ? 'true' : 'false'),
helperText: () => props.error.value || props.helper.value,
helperTone: () => (props.error.value ? 'error' : 'default'),
invalid: () => !!props.error.value,
role: 'radio',
});
reflect({
checked: () => control.checked.value,
classMap: () => ({
'is-checked': control.checked.value,
'is-disabled': effectiveDisabled.value,
}),
color: () => effectiveColor.value,
disabled: () => (effectiveDisabled.value ? true : undefined),
name: () => effectiveName.value || undefined,
onClick: (e: Event) => {
if (effectiveDisabled.value) return;
if (groupCtx) {
groupCtx.select(props.value.value ?? '', e);
} else {
// For non-grouped radios, require a name attribute
// (radios should be part of a group, either via radio-group or via name)
if (!effectiveName.value) return;
// Only toggle if not already checked
// (radio buttons can only be checked, never unchecked by clicking)
if (!control.checked.value) {
// Uncheck all other radios with the same name
const radioName = props.name.value;
const allRadios = document.querySelectorAll<HTMLElement>(`bit-radio[name="${radioName}"]`);
allRadios.forEach((radio) => {
if (radio !== host) {
radio.removeAttribute('checked');
}
});
control.toggle(e);
}
}
},
onKeydown: (e: Event) => {
const ke = e as KeyboardEvent;
const radios = getRadioGroup();
if (radios.length === 0) return;
const currentIndex = radios.indexOf(host);
if (currentIndex === -1) return;
if (ke.key === ' ' || ke.key === 'Enter') {
ke.preventDefault();
if (!control.checked.value) {
if (groupCtx) {
groupCtx.select(props.value.value ?? '', ke);
} else {
control.toggle(ke);
}
}
} else if (ke.key === 'ArrowDown' || ke.key === 'ArrowRight') {
ke.preventDefault();
const nextIndex = (currentIndex + 1) % radios.length;
const nextRadio = radios[nextIndex];
nextRadio.focus();
if (groupCtx) {
groupCtx.select(nextRadio.getAttribute('value') ?? '');
} else {
// For non-grouped radios, trigger the select on the focused radio
nextRadio.click();
}
} else if (ke.key === 'ArrowUp' || ke.key === 'ArrowLeft') {
ke.preventDefault();
const prevIndex = currentIndex === 0 ? radios.length - 1 : currentIndex - 1;
const prevRadio = radios[prevIndex];
prevRadio.focus();
if (groupCtx) {
groupCtx.select(prevRadio.getAttribute('value') ?? '');
} else {
// For non-grouped radios, trigger the select on the focused radio
prevRadio.click();
}
}
},
size: () => effectiveSize.value,
tabindex: () => {
if (effectiveDisabled.value) return undefined;
return control.checked.value ? 0 : -1;
},
});
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" data-a11y-label id="${a11y.labelId}"><slot></slot></span>
<div
class="helper-text"
part="helper-text"
data-a11y-helper
id="${a11y.helperId}"
aria-live="polite"
hidden></div>
`;
},
styles: [...formControlMixins, coarsePointerMixin, sizeVariantMixin(CONTROL_SIZE_PRESET), componentStyles],
tag: 'bit-radio',
});View Radio Group Source
import {
computed,
createContext,
createId,
defineComponent,
effect,
handle,
html,
inject,
onMount,
onSlotChange,
provide,
type ReadonlySignal,
signal,
watch,
} from '@vielzeug/craftit';
import type { ComponentSize, ThemeColor } from '../../types';
import { colorThemeMixin, disabledStateMixin, sizeVariantMixin } from '../../styles';
import { mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import { createChoiceChangeDetail, type ChoiceChangeDetail } from '../shared/utils';
import componentStyles from './radio-group.css?inline';
// ─── Context ──────────────────────────────────────────────────────────────────
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>;
};
export const RADIO_GROUP_CTX = createContext<RadioGroupContext>('RadioGroupContext');
// ─── Types ────────────────────────────────────────────────────────────────────
export type BitRadioGroupProps = {
/** Theme color — propagated to all child bit-radio elements */
color?: ThemeColor;
/** Disable all radios in the group */
disabled?: boolean;
/** Error message shown below the group */
error?: string;
/** Helper text shown below the group */
helper?: string;
/** Legend / label for the fieldset. Required for accessibility. */
label?: string;
/** Form field name — propagated to all child bit-radio elements */
name?: string;
/** Layout direction of the radio options */
orientation?: 'vertical' | 'horizontal';
/** Mark the group as required */
required?: boolean;
/** Size — propagated to all child bit-radio elements */
size?: ComponentSize;
/** Currently selected value */
value?: string;
};
export type BitRadioGroupEvents = {
change: ChoiceChangeDetail;
};
/**
* A fieldset wrapper that groups `bit-radio` elements, provides shared
* `name`, `color`, and `size` via context, and manages roving tabindex
* keyboard navigation.
*
* @element bit-radio-group
*
* @attr {string} label - Legend text (required for a11y)
* @attr {string} value - Currently selected value
* @attr {string} name - Form field name (propagated to all bit-radio children)
* @attr {boolean} disabled - Disable all radios in the group
* @attr {string} error - Error message
* @attr {string} helper - Helper text
* @attr {string} color - Theme color
* @attr {string} size - Component size: 'sm' | 'md' | 'lg'
* @attr {string} orientation - Layout: 'vertical' | 'horizontal'
* @attr {boolean} required - Required field
*
* @fires change - Emitted when a radio is selected. detail: { value: string, values: string[], labels: string[], originalEvent?: Event }
*
* @slot - Place `bit-radio` elements here
*/
export const RADIO_GROUP_TAG = defineComponent<BitRadioGroupProps, BitRadioGroupEvents>({
props: {
color: { default: undefined },
disabled: { default: false },
error: { default: '' },
helper: { default: '' },
label: { default: '' },
name: { default: '' },
orientation: { default: 'vertical' },
required: { default: false },
size: { default: undefined },
value: { default: '' },
},
setup({ emit, host, props }) {
const selectedValue = signal('');
watch(
props.value,
(v) => {
selectedValue.value = (v as string | undefined) ?? '';
},
{ immediate: true },
);
const getSlottedRadios = (): HTMLElement[] => Array.from(host.getElementsByTagName('bit-radio')) as HTMLElement[];
const getLabelForValue = (value: string): string => {
const radio = getSlottedRadios().find((el) => (el.getAttribute('value') ?? '') === value);
return radio?.textContent?.replace(/\s+/g, ' ').trim() || value;
};
const selectRadio = (val: string, originalEvent?: Event) => {
selectedValue.value = val;
const labels = val ? [getLabelForValue(val)] : [];
const values = val ? [val] : [];
emit('change', createChoiceChangeDetail(values, labels, originalEvent));
};
const formCtx = inject(FORM_CTX, undefined);
mountFormContextSync(host, formCtx, props);
provide(RADIO_GROUP_CTX, {
color: props.color,
disabled: computed(() => Boolean(props.disabled.value)),
name: props.name,
select: selectRadio,
size: props.size,
value: selectedValue,
});
// Sync name/color/size/disabled onto slotted bit-radio children.
// Checked state is handled reactively inside bit-radio via group context.
const syncChildren = () => {
for (const radio of getSlottedRadios()) {
const val = radio.getAttribute('value') ?? '';
if (val === selectedValue.value) radio.setAttribute('checked', '');
else radio.removeAttribute('checked');
if (props.name.value) radio.setAttribute('name', props.name.value);
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');
if (props.disabled.value) radio.setAttribute('disabled', '');
else radio.removeAttribute('disabled');
}
};
onMount(() => {
onSlotChange('default', syncChildren);
// Apply group props to already-slotted radios on first mount.
syncChildren();
effect(syncChildren);
// Roving tabindex: only the selected (or first) radio is tabbable
effect(() => {
const radios = getSlottedRadios();
let hasFocusable = false;
for (const radio of radios) {
const isSelected = radio.getAttribute('value') === selectedValue.value;
if (isSelected && !props.disabled.value) {
radio.setAttribute('tabindex', '0');
hasFocusable = true;
} else {
radio.setAttribute('tabindex', '-1');
}
}
// If nothing is selected, make the first non-disabled radio tabbable
if (!hasFocusable && radios.length > 0) {
const first = radios.find((r) => !r.hasAttribute('disabled'));
if (first) first.setAttribute('tabindex', '0');
}
});
// Arrow-key navigation within the group
handle(host, 'keydown', (e: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
const radios = getSlottedRadios().filter((el) => !el.hasAttribute('disabled'));
if (!radios.length) return;
const focused = radios.indexOf(document.activeElement as HTMLElement);
if (focused === -1) return;
e.preventDefault();
const next =
e.key === 'ArrowDown' || e.key === 'ArrowRight'
? (focused + 1) % radios.length
: (focused - 1 + radios.length) % radios.length;
radios[next].focus();
selectRadio(radios[next].getAttribute('value') ?? '', e);
});
handle(host, 'change', (e: Event) => {
if (e.target === host) return;
e.stopPropagation();
selectRadio((e.target as HTMLElement).getAttribute('value') ?? '', e);
});
});
const legendId = createId('radio-group-legend');
const errorId = `${legendId}-error`;
const helperId = `${legendId}-helper`;
const hasError = computed(() => Boolean(props.error.value));
const hasHelper = computed(() => Boolean(props.helper.value) && !hasError.value);
return html`
<fieldset
role="radiogroup"
aria-required="${() => String(Boolean(props.required.value))}"
aria-invalid="${() => String(hasError.value)}"
aria-errormessage="${() => (hasError.value ? errorId : null)}"
aria-describedby="${() => (hasError.value ? errorId : hasHelper.value ? helperId : null)}">
<legend id="${legendId}" ?hidden=${() => !props.label.value}>
${() => props.label.value}${() => (props.required.value ? html`<span aria-hidden="true"> *</span>` : '')}
</legend>
<div class="radio-group-items" part="items">
<slot></slot>
</div>
<div class="error-text" id="${errorId}" role="alert" ?hidden=${() => !hasError.value}>
${() => props.error.value}
</div>
<div class="helper-text" id="${helperId}" ?hidden=${() => !hasHelper.value}>${() => props.helper.value}</div>
</fieldset>
`;
},
styles: [colorThemeMixin, sizeVariantMixin(), disabledStateMixin(), componentStyles],
tag: 'bit-radio-group',
});Standalone Radio
Basic Usage
<bit-radio name="choice" value="option1" checked>Option 1</bit-radio>
<bit-radio name="choice" value="option2">Option 2</bit-radio>
<script type="module">
import '@vielzeug/buildit/radio';
</script>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
bit-radio-group wraps bit-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
<bit-radio-group name="size" label="T-shirt size" value="medium">
<bit-radio value="small">Small</bit-radio>
<bit-radio value="medium">Medium</bit-radio>
<bit-radio value="large">Large</bit-radio>
</bit-radio-group>
<script type="module">
import '@vielzeug/buildit/radio-group';
import '@vielzeug/buildit/radio';
</script>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">
<bit-radio-group name="experience" label="How would you rate your experience?" required>
<bit-radio value="1">Poor</bit-radio>
<bit-radio value="2">Fair</bit-radio>
<bit-radio value="3">Good</bit-radio>
<bit-radio value="4">Excellent</bit-radio>
</bit-radio-group>
<bit-button type="submit">Submit</bit-button>
</form>
<script type="module">
import '@vielzeug/buildit/radio-group';
import '@vielzeug/buildit/radio';
import '@vielzeug/buildit/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
bit-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 |
bit-radio Slots
| Slot | Description |
|---|---|
| (default) | Radio button label content |
bit-radio Events
| Event | Detail | Description |
|---|---|---|
change | { checked: boolean, value: string, originalEvent: Event } | Emitted when checked state changes (only when becoming checked) |
bit-radio CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--radio-size | Size of the circle | Size-dependent |
--radio-checked-bg | Background when checked | Color-dependent |
--radio-color | Inner dot color | white |
bit-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 |
bit-radio-group Slots
| Slot | Description |
|---|---|
| (default) | Place bit-radio elements here |
bit-radio-group Events
| Event | Detail | Description |
|---|---|---|
change | { value: string } | Emitted when a radio is selected |
bit-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.
bit-radio
✅ Keyboard Navigation
Space/Enterselect a radio;Tabmoves focus in and out of the group.- Arrow keys navigate between radios within a group using a roving tabindex.
✅ Screen Readers
- Uses
role="radio"witharia-checkedreflecting the current state. aria-disabledreflects the disabled state.
bit-radio-group
✅ Semantic Structure
- Renders as a
<fieldset>with a<legend>for thelabelattribute.
✅ Screen Readers
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
bit-checkbox-groupinstead.