Skip to content

Radio Group

A set of checkable buttons where only one can be selected at a time.

Updated View as Markdown

Radio Group

A group of radio buttons built on role="radiogroup". Generates hidden native <input type="radio"> elements for form submission, supports keyboard navigation via arrow keys, and handles form reset.

import { RadioGroup } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <div data-slot="radio-group" data-name="plan" class="grid gap-2">
    <label>
      <span data-slot="radio-group-item" data-value="starter" class="win95-check mr-2 rounded-full">
        <span data-slot="radio-group-indicator" class="hidden win95-check-mark rounded-full data-[checked]:block"></span>
      </span>
      Starter
    </label>
    <label>
      <span data-slot="radio-group-item" data-value="pro" class="win95-check mr-2 rounded-full">
        <span data-slot="radio-group-indicator" class="hidden win95-check-mark rounded-full data-[checked]:block"></span>
      </span>
      Pro
    </label>
    <label>
      <span data-slot="radio-group-item" data-value="enterprise" class="win95-check mr-2 rounded-full">
        <span data-slot="radio-group-indicator" class="hidden win95-check-mark rounded-full data-[checked]:block"></span>
      </span>
      Enterprise
    </label>
  </div>
`;

const root = document.querySelector(
  '[data-slot="radio-group"]',
)!;
const controller = RadioGroup.createRadioGroup(root, {
  name: "plan",
  defaultValue: "starter",
});

Import

import { RadioGroup } from "@areia/slots";

Usage

Call createRadioGroup with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages selection state, keyboard navigation, and form integration.

import { RadioGroup } from "@areia/slots";

const root = document.querySelector<HTMLElement>(
  '[data-slot="radio-group"]',
)!;
if (!root) throw new Error("No root element found");

const controller = RadioGroup.createRadioGroup(root, {
  name: "plan",
  defaultValue: "starter",
});

// Listen for state changes
root.addEventListener("radio-group:change", (e) => {
  console.log(e.detail.value);
});

// Programmatic control
controller.select("pro");
// controller.clear();
// controller.destroy();

Auto-bind

Use the create helper to discover and bind every unattached root in a scope.

import { RadioGroup } from "@areia/slots";

const controllers = RadioGroup.create(document);
// controllers.forEach((c) => { ... });

Expected Markup

The primitive expects the following slot structure. Each radio-group-item requires a data-value attribute that identifies it for selection and form submission.

<div data-slot="radio-group" data-name="plan">
  <label>
    <span data-slot="radio-group-item" data-value="starter">
      <span data-slot="radio-group-indicator"></span>
    </span>
    Starter
  </label>

  <label>
    <span data-slot="radio-group-item" data-value="pro">
      <span data-slot="radio-group-indicator"></span>
    </span>
    Pro
  </label>

  <label>
    <span data-slot="radio-group-item" data-value="enterprise">
      <span data-slot="radio-group-indicator"></span>
    </span>
    Enterprise
  </label>
</div>

Wrapping each item in a <label> is recommended — the controller detects wrapping labels and label[for] associations so that clicking the label text selects the corresponding radio.

Items Without Indicators

The radio-group-indicator slot is optional. Items work without it:

<div data-slot="radio-group" data-name="plan">
  <button data-slot="radio-group-item" data-value="starter">
    Starter
  </button>
  <button data-slot="radio-group-item" data-value="pro">
    Pro
  </button>
</div>

When an item is a native <button>, the controller automatically sets type="button" to prevent form interference.

Generated Hidden Inputs

The controller creates a visually hidden native <input type="radio"> for each item and inserts it immediately after the item element:

<input
  type="radio"
  tabindex="-1"
  aria-hidden="true"
  data-radio-group-generated="input"
  style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;pointer-events:none"
/>

These hidden inputs participate in standard form submission and FormData.

Data Slots

Slot Required Description
radio-group Yes Root element. Receives role="radiogroup" and ARIA attrs.
radio-group-item Yes A selectable radio item. Must have a data-value attribute.
radio-group-indicator No Visual indicator shown when the item is checked.

Generated Attributes

Root (radio-group)

Attribute Values Description
role "radiogroup" ARIA radiogroup role.
data-value string Currently selected value. Absent when no item is selected.
data-disabled (present) Present when the entire group is disabled.
data-readonly (present) Present when the entire group is read-only.
data-required (present) Present when the group is required.
aria-disabled "true" or removed Reflects disabled state.
aria-readonly "true" or removed Reflects read-only state.
aria-required "true" or removed Reflects required state.

Items (radio-group-item)

Attribute Values Description
role "radio" ARIA radio role.
data-checked (present) Present when the item is selected.
data-unchecked (present) Present when the item is not selected.
data-disabled (present) Present when the item is disabled (group-level or item-level).
data-readonly (present) Present when the group is read-only.
data-required (present) Present when the group is required.
aria-checked "true" / "false" Reflects selection state.
aria-disabled "true" or removed Reflects disabled state.
aria-readonly "true" or removed Reflects read-only state.
aria-required "true" or removed Reflects required state.
aria-labelledby ID refs Merged from wrapping <label> and label[for] associations.
tabindex 0 / -1 The checked enabled item (or first enabled item) receives 0; others receive -1.

Indicators (radio-group-indicator)

Attribute Values Description
data-checked (present) Mirrors the parent item’s checked state.
data-unchecked (present) Mirrors the parent item’s unchecked state.
data-disabled (present) Mirrors the parent item’s disabled state.
data-readonly (present) Mirrors the group’s read-only state.
data-required (present) Mirrors the group’s required state.

Events

All events are dispatched from the root element (data-slot="radio-group").

Outbound

Event Detail Description
radio-group:change { value: string | null } Fires when the selected value changes.

radio-group:change

interface RadioGroupChangeDetail {
  value: string | null;
}
Field Description
value The newly selected value, or null if the selection was cleared.

Inbound

Event Detail Description
radio-group:set { value: string } | string Set the selected value from outside the controller.

Dispatch radio-group:set on the root to select a radio item without a controller reference. Pass null to clear the selection.

const root = document.querySelector<HTMLElement>(
  '[data-slot="radio-group"]',
)!;

// Select a value via detail object
root?.dispatchEvent(
  new CustomEvent("radio-group:set", {
    bubbles: true,
    detail: { value: "pro" },
  }),
);

// Or with a string shorthand
root?.dispatchEvent(
  new CustomEvent("radio-group:set", {
    bubbles: true,
    detail: "pro",
  }),
);

// Clear selection
root?.dispatchEvent(
  new CustomEvent("radio-group:set", {
    bubbles: true,
    detail: { value: null },
  }),
);

radio-group:set is silently ignored when the group is disabled or readOnly.

API Reference

Options

RadioGroup.createRadioGroup(root, options)

Option Type Default Description
defaultValue string Value of the initially selected item.
name string Form field name applied to all generated radio inputs.
disabled boolean false Disable user interaction and form submission for all items.
readOnly boolean false Prevent user interaction while keeping programmatic control.
required boolean false Require a selected value for native form validation.
onValueChange (value: string | null) => void Called when the selected value changes.

Options can also be set via data-* attributes on the root element (e.g. data-default-value="starter", data-name="plan", data-disabled="true"). JS options take precedence over data attributes.

Controller

Member Type Description
value string | null (getter) Currently selected value.
select(value) (value: string) => void Select the item with the given value. Invalid values are silently ignored.
clear() () => void Clear the current selection.
destroy() () => void Remove listeners, generated inputs, and clean up.

Controller

The controller is the imperative handle returned by createRadioGroup. Use it for programmatic control when you need to select or clear radio items from application logic.

import { RadioGroup } from "@areia/slots";

const root = document.querySelector<HTMLElement>(
  '[data-slot="radio-group"]',
)!;
if (!root) throw new Error("No root element");

const ctrl = RadioGroup.createRadioGroup(root, {
  name: "plan",
  defaultValue: "starter",
});

// Read state
console.log(ctrl.value); // "starter"

// Select an item
ctrl.select("pro");
console.log(ctrl.value); // "pro"

// Invalid values are silently ignored
ctrl.select("nonexistent");
console.log(ctrl.value); // still "pro"

// Clear the selection
ctrl.clear();
console.log(ctrl.value); // null

// Tear down all listeners and clean up
ctrl.destroy();

Behavior

  • Roving tabindex: The checked enabled item (or first enabled item when nothing is checked) receives tabindex="0". All other items receive tabindex="-1". Arrow keys move focus and selection together.
  • Keyboard navigation: ArrowRight / ArrowDown move to the next enabled item. ArrowLeft / ArrowUp move to the previous enabled item. Home / End jump to the first / last enabled item. Space and Enter select the focused item.
  • Form integration: Hidden native <input type="radio"> elements are generated for each item and participate in FormData and standard form submission. The name option is applied to all generated inputs. Form reset restores the initial selection.
  • Item disabling: Items can be disabled individually via disabled, data-disabled, or aria-disabled="true" on the item element. The group-level disabled option disables all items.
  • Wrapping labels: The controller detects <label> wrappers around items and label[for] associations. Clicking an associated label selects the corresponding item.
  • Invalid items: Items without a data-value attribute receive tabindex="-1" and aria-disabled="true" and are excluded from selection.
  • Deduplication: Calling RadioGroup.createRadioGroup() more than once on the same root returns the existing controller. Destroy it first if you need to rebind with new options.
  • Auto-bind: The create() function discovers all [data-slot="radio-group"] elements in a scope and binds any that are not already bound.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close