Skip to content

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

SlotRequiredDescription
radio-groupYesRoot element. Receives role="radiogroup" and ARIA attrs.
radio-group-itemYesA selectable radio item. Must have a data-value attribute.
radio-group-indicatorNoVisual indicator shown when the item is checked.

Generated Attributes

Root (radio-group)

AttributeValuesDescription
role"radiogroup"ARIA radiogroup role.
data-valuestringCurrently 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 removedReflects disabled state.
aria-readonly"true" or removedReflects read-only state.
aria-required"true" or removedReflects required state.

Items (radio-group-item)

AttributeValuesDescription
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 removedReflects disabled state.
aria-readonly"true" or removedReflects read-only state.
aria-required"true" or removedReflects required state.
aria-labelledbyID refsMerged from wrapping <label> and label[for] associations.
tabindex0 / -1The checked enabled item (or first enabled item) receives 0; others receive -1.

Indicators (radio-group-indicator)

AttributeValuesDescription
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

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

radio-group:change

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

Inbound

EventDetailDescription
radio-group:set{ value: string } | stringSet 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)

OptionTypeDefaultDescription
defaultValuestringValue of the initially selected item.
namestringForm field name applied to all generated radio inputs.
disabledbooleanfalseDisable user interaction and form submission for all items.
readOnlybooleanfalsePrevent user interaction while keeping programmatic control.
requiredbooleanfalseRequire a selected value for native form validation.
onValueChange(value: string | null) => voidCalled 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

MemberTypeDescription
valuestring | null (getter)Currently selected value.
select(value)(value: string) => voidSelect the item with the given value. Invalid values are silently ignored.
clear()() => voidClear the current selection.
destroy()() => voidRemove 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.