Skip to content

cv-radio-group ​

Set of mutually exclusive options where only one can be selected at a time.

Headless: createRadioGroup

Cross-Spec Consistency ​

This document is the UIKit surface contract for Radio Group.

  • The canonical state model, invariants, and user-driven transitions are defined by the headless spec.
  • Any intentional divergence between UIKit and headless MUST be explicitly documented in both specs to prevent drift.

Usage ​

View source
html
<div class="radio-demo-shell" data-demo="radio-group" data-live-demo-height="1080">
  <section class="radio-demo-hero" aria-labelledby="radio-demo-title">
    <div class="radio-demo-copy">
      <span class="radio-demo-kicker">Single-choice control</span>
      <h3 id="radio-demo-title">Use radio-group when the answer must be exactly one branch.</h3>
      <p>
        The group owns roving focus, ARIA, form value, and change events. Each <code>cv-radio</code> stays
        presentational, so product screens can render options without duplicating selection logic.
      </p>
    </div>

    <dl class="radio-demo-metrics" aria-label="Radio group contract summary">
      <div>
        <dt>Keyboard</dt>
        <dd>Arrow keys, Home, End, Space</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>single value</dd>
      </div>
      <div>
        <dt>Events</dt>
        <dd>cv-input / cv-change</dd>
      </div>
    </dl>
  </section>

  <section class="radio-demo-workbench" aria-labelledby="radio-demo-workbench-title">
    <div class="radio-demo-section-header">
      <span class="radio-demo-kicker">Vault route picker</span>
      <h4 id="radio-demo-workbench-title">
        Select one visible branch while the group keeps form semantics and focus order.
      </h4>
    </div>

    <div class="radio-demo-board">
      <form class="radio-demo-panel radio-demo-panel--primary" aria-label="Vault route selection">
        <header class="radio-demo-panel-header">
          <div>
            <span class="radio-demo-label">Decision point</span>
            <strong>Unlock target for this session</strong>
          </div>
          <cv-badge variant="primary">required</cv-badge>
        </header>

        <cv-radio-group
          class="radio-demo-primary"
          name="vault-route"
          value="hidden"
          orientation="vertical"
          required
          aria-label="Vault route"
        >
          <cv-radio value="visible">
            Visible vault
            <span slot="description"
              >Open the ordinary workspace that can be shown under routine review.</span
            >
          </cv-radio>
          <cv-radio value="hidden">
            Hidden namespace
            <span slot="description">Continue into the protected branch after local policy has passed.</span>
          </cv-radio>
          <cv-radio value="decoy" disabled>
            Decoy profile
            <span slot="description">Unavailable because the current route is already mounted.</span>
          </cv-radio>
        </cv-radio-group>

        <output class="radio-demo-output" aria-live="polite">
          Selected route: Hidden namespace · value=hidden
        </output>
      </form>

      <aside class="radio-demo-panel radio-demo-panel--contract" aria-label="Current radio group contract">
        <span class="radio-demo-label">Live contract</span>
        <dl class="radio-demo-live">
          <div>
            <dt>value</dt>
            <dd data-radio-current-value>hidden</dd>
          </div>
          <div>
            <dt>activeId</dt>
            <dd data-radio-current-active>hidden</dd>
          </div>
          <div>
            <dt>orientation</dt>
            <dd>vertical</dd>
          </div>
          <div>
            <dt>disabled item</dt>
            <dd>decoy</dd>
          </div>
        </dl>
      </aside>
    </div>
  </section>

  <section class="radio-demo-section" aria-labelledby="radio-demo-states-title">
    <div class="radio-demo-section-header">
      <span class="radio-demo-kicker">States and variants</span>
      <h4 id="radio-demo-states-title">
        Default radios, segmented controls, descriptions, disabled groups, and size scale.
      </h4>
    </div>

    <div class="radio-demo-state-grid">
      <div class="radio-demo-cell radio-demo-cell--wide">
        <span class="radio-demo-label">Segmented density</span>
        <cv-radio-group variant="segmented" value="manual" aria-label="Unlock mode">
          <cv-radio value="auto">Auto</cv-radio>
          <cv-radio value="manual">Manual</cv-radio>
          <cv-radio value="sealed">Sealed</cv-radio>
        </cv-radio-group>
      </div>

      <div class="radio-demo-cell">
        <span class="radio-demo-label">Horizontal default</span>
        <cv-radio-group value="local" aria-label="Sync target">
          <cv-radio value="local">Local</cv-radio>
          <cv-radio value="relay">Relay</cv-radio>
          <cv-radio value="usb">USB</cv-radio>
        </cv-radio-group>
      </div>

      <div class="radio-demo-cell">
        <span class="radio-demo-label">With descriptions</span>
        <cv-radio-group value="owner" orientation="vertical" aria-label="Recovery owner">
          <cv-radio value="owner">
            Owner key
            <span slot="description">Primary recovery material.</span>
          </cv-radio>
          <cv-radio value="delegate">
            Delegate key
            <span slot="description">Secondary approval path.</span>
          </cv-radio>
        </cv-radio-group>
      </div>

      <div class="radio-demo-cell radio-demo-cell--muted">
        <span class="radio-demo-label">Disabled group</span>
        <cv-radio-group value="policy" disabled aria-label="Locked policy">
          <cv-radio value="policy">Policy</cv-radio>
          <cv-radio value="manual">Manual</cv-radio>
        </cv-radio-group>
      </div>

      <div class="radio-demo-cell radio-demo-cell--wide">
        <span class="radio-demo-label">Size scale</span>
        <cv-radio-group value="medium" aria-label="Radio size examples">
          <cv-radio value="small" size="small">Small</cv-radio>
          <cv-radio value="medium" size="medium">Medium</cv-radio>
          <cv-radio value="large" size="large">Large</cv-radio>
        </cv-radio-group>
      </div>
    </div>
  </section>
</div>

<script>
  document
    .querySelectorAll('.radio-demo-shell[data-demo="radio-group"]:not([data-ready])')
    .forEach((shell) => {
      shell.dataset.ready = 'true'

      const group = shell.querySelector('.radio-demo-primary')
      const output = shell.querySelector('.radio-demo-output')
      const valueNode = shell.querySelector('[data-radio-current-value]')
      const activeNode = shell.querySelector('[data-radio-current-active]')
      const labels = {
        visible: 'Visible vault',
        hidden: 'Hidden namespace',
        decoy: 'Decoy profile',
      }

      const renderState = (detail = {}) => {
        const value = detail.value || group?.value || 'hidden'
        const activeId = detail.activeId || value
        const label = labels[value] || value

        if (output) {
          output.textContent = `Selected route: ${label} · value=${value}`
        }
        if (valueNode) {
          valueNode.textContent = value
        }
        if (activeNode) {
          activeNode.textContent = activeId
        }
      }

      group?.addEventListener('cv-change', (event) => {
        renderState(event.detail)
      })
      renderState()
    })
</script>

Anatomy ​

<cv-radio-group> (host)
└── <div part="base" role="radiogroup">
    └── <slot>                              ← accepts <cv-radio> children

Attributes ​

AttributeTypeDefaultDescription
valueString""Value of the currently selected radio
orientationString"horizontal"Layout: horizontal | vertical
variantString"default"Visual variant: default | segmented
disabledBooleanfalsePrevents interaction for all radios
aria-labelString""Accessible label for the group

Slots ​

SlotDescription
(default)<cv-radio> children

CSS Parts ​

PartElementDescription
base<div>Root layout container with role="radiogroup"

CSS Custom Properties ​

PropertyDefaultDescription
--cv-radio-group-gapvar(--cv-space-2, 8px)Spacing between radio items

Visual States ​

Host selectorDescription
:host([disabled])All child radios are non-interactive
:host([orientation="horizontal"])Items stay on one row and scroll inline when constrained
:host([orientation="vertical"])Items stacked vertically
:host([variant="segmented"])Segmented-control visual treatment

Reactive State Mapping ​

cv-radio-group is a visual adapter over headless createRadioGroup.

UIKit PropertyDirectionHeadless Binding
valueattr → actionactions.select(value) on change; initial passed as initialValue option
disabledattr → actionactions.setDisabled(value)
orientationattr → optionpassed as orientation in createRadioGroup(options)
aria-labelattr → optionpassed as ariaLabel in createRadioGroup(options)
variantattr → childreflected to child cv-radio[variant]; not part of headless state
Headless StateDirectionDOM Reflection
state.value()state → attr[value] host attribute; reflected onto cv-radio[checked]
state.activeId()state → attrreflected onto cv-radio[active]
state.isDisabled()state → attr[disabled] host attribute
  • contracts.getRootProps() is spread onto the inner [part="base"] element to apply role, aria-label, aria-disabled, aria-orientation, and onKeyDown handler.
  • contracts.getRadioProps(id) is spread onto each cv-radio child to apply role, tabindex, aria-checked, aria-disabled, aria-describedby, data-active, onClick, and onKeyDown.
  • UIKit dispatches cv-input and cv-change events by observing state.value() changes triggered by user activation (not by controlled attribute updates).
  • UIKit does not own selection or navigation logic; headless state is the source of truth.

Events ​

EventDetailDescription
cv-input{value: string, activeId: string}Fires on user selection interaction
cv-change{value: string, activeId: string}Fires when selected value commits

Child Elements ​

cv-radio ​

Individual radio option within a radio group. Purely presentational — all state and ARIA are managed by the parent cv-radio-group.

Anatomy ​

<cv-radio> (host)
└── <div part="base">
    ├── <span part="indicator">
    │   └── <span part="dot">
    ├── <span part="label">
    │   └── <slot>
    └── <span part="description">
        └── <slot name="description">

Attributes ​

AttributeTypeDefaultDescription
valueString""Unique identifier for this radio option
disabledBooleanfalsePrevents interaction for this radio
checkedBooleanfalseWhether this radio is selected (managed by group)
activeBooleanfalseWhether this radio has roving focus (managed by group)
sizeString"medium"Size: small | medium | large
variantString"default"Visual variant: default | segmented

Sizes ​

Size--cv-radio-indicator-size--cv-radio-dot-size
small16px6px
medium20px8px
large24px10px

Slots ​

SlotDescription
(default)Label text for the radio option
descriptionSecondary text displayed below the label

CSS Parts ​

PartElementDescription
base<div>Border-box root wrapper safe for percentage sizing
indicator<span>Circular border container for the dot
dot<span>Inner filled circle (visible when checked)
label<span>Wrapper around the default slot
description<span>Wrapper around the description slot

CSS Custom Properties ​

PropertyDefaultDescription
--cv-radio-indicator-size20pxOuter size of the radio circle
--cv-radio-dot-size8pxInner dot size when checked
--cv-radio-gapvar(--cv-space-2, 8px)Spacing between indicator and label

Visual States ​

Host selectorDescription
:host([checked])Primary-tinted indicator border, dot visible
:host([disabled])Reduced opacity (0.55), cursor: not-allowed
:host([active])Focused radio in roving tabindex model
:host(:focus-visible)Focus ring on the host element
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides
:host([variant="segmented"])Button-like segmented radio without circular indicator

Events ​

None. All events are dispatched by the parent cv-radio-group.

Parity Matrix (Headless vs UIKit) ​

SurfaceHeadlessUIKit
Selection modelsingle selection via value atomvalue attribute on group
Focus modelroving tabindex via activeId atomactive attribute on radio
Disabled semanticsgroup-level + per-itemdisabled on group + individual radio
Navigationarrow keys with wrapping, Home/Enddelegated to headless handleKeyDown
Description linkagedescribedBy on RadioGroupItemdescription slot with aria-describedby
Sizenot applicablesmall | medium | large on radio
Variantnot applicabledefault | segmented visual variant
Orientationorientation optionorientation attribute on group
EventsN/A (actions/state API)cv-input / cv-change with {value, activeId}

ChromVoid UIKit documentation