Skip to content

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, propagates color, size, name, and disabled to 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
ts
import { computed, define, html, inject } from '@vielzeug/craftit';
import {
  type CheckableChangePayload,
  createCheckableFieldControl,
  createListControl,
} from '@vielzeug/craftit/controls';

import type { CheckableProps, DisablableProps, SizableProps, ThemableProps } from '../../types';

import { coarsePointerMixin, formControlMixins, sizeVariantMixin } from '../../styles';
import { RADIO_GROUP_CTX } from '../radio-group/radio-group';
import { disablableBundle, sizableBundle, themableBundle } from '../shared/bundles';
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: CheckableChangePayload;
};

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: { checked: boolean, value: string, originalEvent?: Event }
 *
 * @slot - Radio button label text
 *
 * @cssprop --border-2 - Border token.
 * @cssprop --color-contrast - Contrast color token for text and surfaces.
 * @cssprop --color-contrast-200 - Contrast color token for text and surfaces.
 * @cssprop --color-contrast-300 - Contrast color token for text and surfaces.
 * @cssprop --color-contrast-500 - Contrast color token for text and surfaces.
 * @cssprop --color-error - Error state color token.
 * @cssprop --leading-tight - Line-height token.
 * @cssprop --radio-bg - Radio control styling token.
 * @cssprop --radio-border-color - Radio control styling token.
 * @cssprop --radio-checked-bg - Radio control styling token.
 * @cssprop --radio-color - Radio control styling token.
 * @cssprop --radio-font-size - Radio control styling token.
 * @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
 * <bit-radio></bit-radio>
 * ```
 */
export const RADIO_TAG = define<BitRadioProps, BitRadioEvents>('bit-radio', {
  formAssociated: true,
  props: {
    ...themableBundle,
    ...sizableBundle,
    ...disablableBundle,
    checked: { default: false, reflect: false }, // managed by host.bind (form-control derived state)
    error: '',
    helper: '',
    name: { default: '', reflect: false }, // managed by host.bind (effective name from radio-group)
    value: '',
  },
  setup(props, { emit, host }) {
    const groupCtx = inject(RADIO_GROUP_CTX);
    const formCtx = inject(FORM_CTX);

    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 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>(`bit-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();
    };

    let activeIndex = -1;

    const listControl = createListControl({
      getIndex: () => activeIndex,
      getItems: () => getRadioGroup(),
      keys: { next: ['ArrowDown', 'ArrowRight'], prev: ['ArrowUp', 'ArrowLeft'] },
      loop: true,
      onNavigate: (_action, _index, event) => {
        const nextRadio = getRadioGroup()[activeIndex];

        if (nextRadio) selectRadio(nextRadio, event);
      },
      setIndex: (index) => {
        activeIndex = index;
        getRadioGroup()[index]?.focus();
      },
    });

    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'));
    };

    const checkable = createCheckableFieldControl({
      checked: checkedFromState,
      disabled: computed(
        () => Boolean(props.disabled.value) || Boolean(groupCtx?.disabled.value) || Boolean(formCtx?.disabled.value),
      ),
      error: props.error,
      helper: props.helper,
      onPress: (_control, originalEvent) => {
        activateSelf(originalEvent);
      },
      onToggle: (payload) => {
        emit('change', payload);
      },
      prefix: 'radio',
      role: 'radio',
      validateOn: formCtx?.validateOn,
      value: props.value,
    });
    const { checked, disabled, handleKeydown, helperId, labelId, toggle } = checkable;

    mountFormContextSync(host.el, formCtx, props);

    host.bind({
      attr: {
        checked,
        color: effectiveColor,
        disabled: () => (disabled.value ? true : undefined),
        name: () => effectiveName.value || undefined,
        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>(`bit-radio[name="${radioName}"]`);

              allRadios.forEach((radio) => {
                if (radio !== host.el) radio.removeAttribute('checked');
              });

              toggle(e);
            }
          }
        },
        keydown: (e: KeyboardEvent) => {
          const radios = getRadioGroup();

          if (radios.length === 0) return;

          activeIndex = radios.indexOf(host.el);

          if (activeIndex === -1) return;

          if (handleKeydown(e)) return;

          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" data-a11y-label id="${labelId}"><slot></slot></span>
      <div class="helper-text" part="helper-text" data-a11y-helper id="${helperId}" aria-live="polite" hidden></div>
    `;
  },
  styles: [...formControlMixins, coarsePointerMixin, sizeVariantMixin(CONTROL_SIZE_PRESET), componentStyles],
});
View Radio Group Source
ts
import {
  computed,
  createContext,
  createId,
  define,
  effect,
  html,
  inject,
  prop,
  provide,
  signal,
  when,
} from '@vielzeug/craftit';
import { createListControl } from '@vielzeug/craftit/controls';

import { colorThemeMixin, disabledStateMixin, sizeVariantMixin } from '../../styles';
import { mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import {
  createChoiceChangeDetail,
  getChoiceLabel,
  getSlottedByTag,
  setBooleanAttribute,
  setMaybeAttribute,
  syncSignalFromProp,
} from '../shared/utils';
import componentStyles from './radio-group.css?inline';

/** Radio group component properties */
export type BitRadioGroupProps = {
  /** Theme color tint */
  color?: string;
  /** 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?: string;
  /** Initial selected value */
  value?: string;
};

/** Shared context for radio groups */
export type RadioGroupContext = {
  color: { value: string | undefined };
  disabled: { value: boolean };
  name: { value: string | undefined };
  select: (value: string, originalEvent?: Event) => void;
  size: { value: string | undefined };
  value: { value: string };
};

export const RADIO_GROUP_CTX = createContext<RadioGroupContext | undefined>('BitRadioGroup');

/** Events emitted by the radio-group component */
export type BitRadioGroupEvents = {
  /** Emitted when the selection changes */
  change: {
    labels: string[];
    originalEvent?: Event;
    value: string;
    values: string[];
  };
};

/**
 * 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 bit-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: { value: string, values: string[], labels: string[], originalEvent?: Event }
 *
 * @slot - Place `bit-radio` elements here
 *
 * @cssprop --color-contrast-500 - Contrast color token for text and surfaces.
 * @cssprop --color-contrast-600 - Contrast color token for text and surfaces.
 * @cssprop --color-error - Error state color token.
 * @cssprop --font-medium - Font-weight token.
 * @cssprop --leading-tight - Line-height token.
 * @cssprop --radio-group-direction - Radio control styling token.
 * @cssprop --radio-group-gap - Radio control styling token.
 * @cssprop --size-1-5 - Spacing/sizing token.
 * @cssprop --size-2 - Spacing/sizing token.
 * @cssprop --text-sm - Font-size token.
 * @cssprop --text-xs - Font-size token.
 * @part items - Items container.
 * @example
 * ```html
 * <bit-radio-group></bit-radio-group>
 * ```
 */
export const RADIO_GROUP_TAG = define<BitRadioGroupProps, BitRadioGroupEvents>('bit-radio-group', {
  props: {
    color: undefined,
    disabled: false,
    error: undefined,
    helper: undefined,
    label: undefined,
    name: undefined,
    orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'vertical'),
    required: false,
    size: undefined,
    value: { default: '', reflect: false }, // managed by host.bind (selectedValue derived state)
  },
  setup(props, { emit, host, slots }) {
    const selectedValue = signal((props.value.value as string | undefined) ?? '');
    const isDisabled = computed(() => Boolean(props.disabled.value));

    host.bind({
      attr: {
        value: () => selectedValue.value || null,
      },
    });

    syncSignalFromProp(props.value, {
      get value() {
        return selectedValue.value;
      },
      set value(v) {
        selectedValue.value = (v as string | undefined) ?? '';
      },
    });

    const getSlottedRadios = (): HTMLElement[] => getSlottedByTag(host.el, 'bit-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) => {
      selectedValue.value = val;

      const labels = val ? [getLabelForValue(val)] : [];
      const values = val ? [val] : [];

      emit('change', createChoiceChangeDetail(values, labels, originalEvent));
    };

    const formCtx = inject(FORM_CTX);

    mountFormContextSync(host.el, formCtx, props);

    provide(RADIO_GROUP_CTX, {
      color: props.color,
      disabled: isDisabled,
      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') ?? '';

        setBooleanAttribute(radio, 'checked', val === selectedValue.value);
        setMaybeAttribute(radio, 'name', props.name.value);
        setMaybeAttribute(radio, 'color', props.color.value);
        setMaybeAttribute(radio, 'size', props.size.value);
        setBooleanAttribute(radio, 'disabled', isDisabled.value);
      }
    };

    effect(() => {
      void slots.elements().value;
      syncChildren();
    });

    // Roving tabindex: only the selected (or first) radio is tabbable
    effect(() => {
      void slots.elements().value;

      const radios = getSlottedRadios();
      const setTabIndex = (radio: HTMLElement, selected: boolean) => {
        radio.setAttribute('tabindex', selected && !isDisabled.value ? '0' : '-1');
      };
      let hasFocusable = false;

      for (const radio of radios) {
        const isSelected = radio.getAttribute('value') === selectedValue.value;

        setTabIndex(radio, isSelected);

        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>({
      getIndex: () => getEnabledRadios().indexOf(document.activeElement as HTMLElement),
      getItems: getEnabledRadios,
      keys: { next: ['ArrowDown', 'ArrowRight'], prev: ['ArrowUp', 'ArrowLeft'] },
      loop: true,
      onNavigate: (_action, _index, event) => {
        const activeRadio = document.activeElement as HTMLElement | null;

        if (activeRadio?.tagName === 'BIT-RADIO') {
          selectRadio(activeRadio.getAttribute('value') ?? '', event);
        }
      },
      setIndex: (index) => {
        const radio = getEnabledRadios()[index];

        if (!radio) return;

        radio.focus();
      },
    });

    host.bind({
      on: {
        change: (e: Event) => {
          if (e.target === host.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.handleKeydown(e);
        },
      },
    });

    const legendId = createId('radio-group-legend');
    const errorId = `${legendId}-error`;
    const helperId = `${legendId}-helper`;
    const hasError = () => Boolean(props.error.value);
    const hasHelper = () => Boolean(props.helper.value) && !hasError();

    return () => html`
      <fieldset
        role="radiogroup"
        aria-required="${() => String(Boolean(props.required.value))}"
        aria-invalid="${() => String(hasError())}"
        aria-errormessage="${() => (hasError() ? errorId : null)}"
        aria-describedby="${() => (hasError() ? errorId : hasHelper() ? helperId : null)}">
        <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="error-text" id="${errorId}" role="alert" ?hidden=${() => !hasError()}>${props.error}</div>
        <div class="helper-text" id="${helperId}" ?hidden=${() => !hasHelper()}>${props.helper}</div>
      </fieldset>
    `;
  },
  styles: [colorThemeMixin, sizeVariantMixin(), disabledStateMixin(), componentStyles],
});

Standalone Radio

Basic Usage

html
<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.

PreviewCode
RTL

Sizes

Three sizes for different contexts.

PreviewCode
RTL

Disabled

Prevent interaction and reduce opacity for unavailable options.

PreviewCode
RTL

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

html
<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

PreviewCode
RTL

Colors & Sizes

Color and size set on the group are automatically propagated to all child radios.

PreviewCode
RTL

Helper & Error Text

PreviewCode
RTL

Disabled

PreviewCode
RTL

In a Form

The selected value attribute is submitted with the form under the name field name.

html
<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

AttributeTypeDefaultDescription
checkedbooleanfalseRadio button checked state
disabledbooleanfalseDisable the radio button
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error''primary'Semantic color
size'sm' | 'md' | 'lg''md'Radio button size
namestringForm field name (required for grouping)
valuestringForm field value when checked

bit-radio Slots

SlotDescription
(default)Radio button label content

bit-radio Events

EventDetailDescription
change{ checked: boolean, value: string, originalEvent: Event }Emitted when checked state changes (only when becoming checked)

bit-radio CSS Custom Properties

PropertyDescriptionDefault
--radio-sizeSize of the circleSize-dependent
--radio-checked-bgBackground when checkedColor-dependent
--radio-colorInner dot colorwhite

bit-radio-group Attributes

AttributeTypeDefaultDescription
labelstring''Legend text — required for accessibility
valuestring''Currently selected value
namestring''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
disabledbooleanfalseDisable all radios in the group
errorstring''Error message shown below the group
helperstring''Helper text (hidden when error is set)
requiredbooleanfalseMark the group as required

bit-radio-group Slots

SlotDescription
(default)Place bit-radio elements here

bit-radio-group Events

EventDetailDescription
change{ value: string }Emitted when a radio is selected

bit-radio-group CSS Custom Properties

PropertyDescriptionDefault
--radio-group-gapSpacing between optionsvar(--size-2)
--radio-group-directionFlex direction (column/row)column

Accessibility

The radio components follow WCAG 2.1 Level AA standards.

bit-radio

Keyboard Navigation

  • Space / Enter select a radio; Tab moves focus in and out of the group.
  • Arrow keys navigate between radios within a group using a roving tabindex.

Screen Readers

  • Uses role="radio" with aria-checked reflecting the current state.
  • aria-disabled reflects the disabled state.

bit-radio-group

Semantic Structure

  • Renders as a <fieldset> with a <legend> for the label attribute.

Screen Readers

  • aria-required and aria-invalid reflect the validation state; aria-errormessage and aria-describedby link the text nodes.

Best Practices

  • Always provide a meaningful label on the group — it is read before each option by screen readers.
  • Always use the name attribute (or set it once on the group) so radios are mutually exclusive.
  • Provide a default value when a sensible default exists.
  • For non-mutually exclusive choices, use bit-checkbox-group instead.