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" data-theme="dark">
  <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="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>Root layout wrapper
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