Skip to content

cv-option

Individual selectable option for use as a direct child of cv-listbox or cv-listbox-group. All ARIA attributes are managed exclusively by the parent cv-listbox via headless contracts.getOptionProps(id); cv-option itself sets no ARIA.

Note: cv-option has no headless module of its own. It is a purely presentational element. See the Parent-managed ARIA Contract section for the full attribute contract imposed by the parent.

Usage

cv-option is not a standalone control. Place it under cv-listbox or cv-listbox-group; the parent listbox owns ARIA attributes, selection, keyboard behavior, and events.

The demo keeps the ownership boundary explicit: cv-option renders row content, while cv-listbox owns focus, selection, ARIA, and events.

View source
html
<div class="option-demo-shell" data-demo="option" data-live-demo-height="780">
  <section class="option-demo-hero" aria-labelledby="option-demo-title">
    <div class="option-demo-copy">
      <span class="option-demo-kicker">Composable row primitive</span>
      <h3 id="option-demo-title">Render the visible choice. Let the parent own the contract.</h3>
      <p>
        <code>cv-option</code> is intentionally dumb: it exposes a label, prefix, and suffix layout, then
        accepts <code>selected</code>, <code>active</code>, and <code>disabled</code> state from its parent.
      </p>
    </div>

    <dl class="option-demo-contract" aria-label="Option contract summary">
      <div>
        <dt>Owns</dt>
        <dd>row layout, slots, visual states</dd>
      </div>
      <div>
        <dt>Parent owns</dt>
        <dd>role, tabindex, selection, events</dd>
      </div>
      <div>
        <dt>Slots</dt>
        <dd>prefix, label, suffix</dd>
      </div>
    </dl>
  </section>

  <section class="option-demo-workbench" aria-labelledby="option-demo-workbench-title">
    <div class="option-demo-section-header">
      <span class="option-demo-kicker">Live listbox context</span>
      <h4 id="option-demo-workbench-title">
        Rich rows stay presentational while the listbox synchronizes active and selected state.
      </h4>
    </div>

    <div class="option-demo-grid">
      <article class="option-demo-panel option-demo-panel--primary" aria-labelledby="option-demo-route-title">
        <header class="option-demo-panel-header">
          <div>
            <span class="option-demo-label">Single select</span>
            <h5 id="option-demo-route-title">Vault exposure route</h5>
          </div>
          <cv-badge variant="primary">parent managed</cv-badge>
        </header>

        <cv-listbox class="option-demo-listbox" aria-label="Vault exposure route">
          <cv-option value="daily" data-label="Daily vault">
            <span slot="prefix" class="option-demo-glyph">D</span>
            <span class="option-demo-option-copy">
              <strong>Daily vault</strong>
              <small>Normal visible workspace</small>
            </span>
            <cv-badge slot="suffix" variant="neutral">stable</cv-badge>
          </cv-option>
          <cv-option value="travel" data-label="Travel profile" selected>
            <span slot="prefix" class="option-demo-glyph option-demo-glyph--violet">T</span>
            <span class="option-demo-option-copy">
              <strong>Travel profile</strong>
              <small>Deniable border surface</small>
            </span>
            <cv-badge slot="suffix" variant="primary">visible</cv-badge>
          </cv-option>
          <cv-option value="sealed" data-label="Sealed core">
            <span slot="prefix" class="option-demo-glyph option-demo-glyph--success">S</span>
            <span class="option-demo-option-copy">
              <strong>Sealed core</strong>
              <small>Requires hardware proof</small>
            </span>
            <cv-badge slot="suffix" variant="success">paired</cv-badge>
          </cv-option>
          <cv-option value="recovery" data-label="Remote recovery" disabled>
            <span slot="prefix" class="option-demo-glyph option-demo-glyph--muted">R</span>
            <span class="option-demo-option-copy">
              <strong>Remote recovery</strong>
              <small>Disabled until quorum returns</small>
            </span>
            <cv-badge slot="suffix" variant="neutral">locked</cv-badge>
          </cv-option>
        </cv-listbox>
      </article>

      <article class="option-demo-panel" aria-labelledby="option-demo-slots-title">
        <header class="option-demo-panel-header">
          <div>
            <span class="option-demo-label">Slot composition</span>
            <h5 id="option-demo-slots-title">Operational row shapes</h5>
          </div>
          <cv-badge variant="neutral">prefix + suffix</cv-badge>
        </header>

        <cv-listbox class="option-demo-listbox" selection-mode="multiple" aria-label="Export review fields">
          <cv-option value="name" data-label="Record name" selected>
            <span slot="prefix" class="option-demo-token">name</span>
            <span class="option-demo-option-copy">
              <strong>Record name</strong>
              <small>Shown in review table</small>
            </span>
          </cv-option>
          <cv-option value="owner" data-label="Owner" selected>
            <span slot="prefix" class="option-demo-token">own</span>
            <span class="option-demo-option-copy">
              <strong>Owner</strong>
              <small>Operational accountability</small>
            </span>
            <span slot="suffix" class="option-demo-value">required</span>
          </cv-option>
          <cv-option value="risk" data-label="Risk flag">
            <span slot="prefix" class="option-demo-token option-demo-token--violet">risk</span>
            <span class="option-demo-option-copy">
              <strong>Risk flag with a longer label that wraps cleanly</strong>
              <small>Visible in elevated review</small>
            </span>
            <span slot="suffix" class="option-demo-value">optional</span>
          </cv-option>
          <cv-option value="secret" data-label="Raw secret" disabled>
            <span slot="prefix" class="option-demo-token option-demo-token--muted">raw</span>
            <span class="option-demo-option-copy">
              <strong>Raw secret</strong>
              <small>Never exported from this route</small>
            </span>
          </cv-option>
        </cv-listbox>
      </article>
    </div>

    <output class="option-demo-readout" aria-live="polite" data-option-readout>
      Option events are emitted by the parent listbox.
    </output>

    <article class="option-demo-state-board" aria-labelledby="option-demo-states-title">
      <div>
        <span class="option-demo-label">Visual state matrix</span>
        <h5 id="option-demo-states-title">Host attributes supplied by the parent</h5>
      </div>

      <div class="option-demo-state-grid">
        <cv-option value="default">Default option</cv-option>
        <cv-option value="active" active>Active option</cv-option>
        <cv-option value="selected" selected>Selected option</cv-option>
        <cv-option value="disabled" disabled>Disabled option</cv-option>
      </div>
    </article>
  </section>
</div>

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

    const readout = shell.querySelector('[data-option-readout]')
    const getLabel = (option) =>
      option?.dataset.label || option?.textContent?.trim().replace(/\s+/g, ' ') || 'none'
    const getSelectedLabels = (listbox) =>
      Array.from(listbox?.querySelectorAll('cv-option') ?? [])
        .filter((option) => option.selected || option.hasAttribute('selected'))
        .map(getLabel)

    const update = () => {
      if (!readout) return

      const route = shell.querySelector('cv-listbox[aria-label="Vault exposure route"]')
      const fields = shell.querySelector('cv-listbox[selection-mode="multiple"]')
      const routeSelected = getSelectedLabels(route)[0] || 'none'
      const routeActive = getLabel(route?.querySelector('cv-option[data-active="true"]'))
      const fieldSelected = getSelectedLabels(fields)

      readout.textContent = `Route: ${routeSelected}. Active option: ${routeActive}. Export fields: ${
        fieldSelected.length > 0 ? fieldSelected.join(', ') : 'none'
      }.`
    }

    shell.querySelectorAll('cv-listbox').forEach((listbox) => {
      listbox.addEventListener('cv-input', update)
      listbox.addEventListener('cv-change', update)
    })

    requestAnimationFrame(update)
  })
</script>

Anatomy

<cv-option> (host)
└── <div part="base">
    ├── <span part="prefix">
    │   └── <slot name="prefix">
    ├── <span part="label">
    │   └── <slot>
    └── <span part="suffix">
        └── <slot name="suffix">

Attributes

AttributeTypeDefaultDescription
valueString""Unique identifier for this option. The parent cv-listbox reads this to register the option in the headless model. Auto-generated as option-{n} if omitted.
disabledBooleanfalseWhether the option is disabled. Prevents selection and keyboard activation. Also reflected as aria-disabled by the parent via getOptionProps.
selectedBooleanfalseWhether the option is currently selected. Set by the parent cv-listbox as part of state synchronisation.
activeBooleanfalseWhether the option is the active (highlighted) option. Set by the parent cv-listbox as part of state synchronisation.

Slots

SlotDescription
(default)Option label text. Also used as the source text for typeahead matching by the parent cv-listbox.
prefixIcon or element placed before the label.
suffixIcon or element placed after the label.

CSS Parts

PartElementDescription
base<div>Root wrapper; receives layout, background, and transition styles.
label<span>Wrapper around the default slot (label text).
prefix<span>Wrapper around the prefix named slot.
suffix<span>Wrapper around the suffix named slot.

CSS Custom Properties

PropertyDefaultDescription
--cv-option-padding-blockvar(--cv-space-2, 8px)Vertical padding inside [part="base"].
--cv-option-padding-inlinevar(--cv-space-3, 12px)Horizontal padding inside [part="base"].
--cv-option-border-radiusvar(--cv-radius-sm, 6px)Border radius of [part="base"].
--cv-option-active-backgroundvar(--cv-color-primary-ring)Background applied when the option is active (highlighted).
--cv-option-selected-backgroundvar(--cv-color-primary-border)Background applied when the option is selected.
--cv-option-disabled-opacity0.55Opacity applied when the option is disabled.
--cv-option-focus-outline-colorvar(--cv-color-primary, #65d7ff)Outline color for :focus-visible (roving-tabindex focus strategy).

Additionally, component styles depend on theme tokens through fallback values:

Theme PropertyDefaultDescription
--cv-color-text#e8ecf6Default text color.
--cv-color-primary#65d7ffPrimary accent color used for active/selected backgrounds and focus outline.
--cv-duration-fast120msBackground and color transition duration.
--cv-easing-standardeaseTransition timing function.
--cv-space-28pxFallback for vertical padding.
--cv-space-312pxFallback for horizontal padding.
--cv-radius-sm6pxFallback for border radius.

Visual States

Host selectorDescription
:host([active])Active/highlighted option; applies --cv-option-active-background to [part="base"].
:host([selected])Selected option; applies --cv-option-selected-background to [part="base"].
:host([disabled])Disabled option; applies --cv-option-disabled-opacity to [part="base"].
:host([hidden])Hidden option; display: none on the host.
:host(:focus-visible)Focus ring when the option receives DOM focus (roving-tabindex strategy); 2px solid outline using --cv-option-focus-outline-color.

Events

cv-option emits no events. All interaction events (input, change) are dispatched by the parent cv-listbox.

Parent-managed ARIA Contract

cv-option sets no ARIA attributes itself. The parent cv-listbox spreads contracts.getOptionProps(id) directly onto each cv-option host element. The following attributes are applied by the parent:

AttributeSourceDescription
idgetOptionProps(id)Unique DOM id used by aria-activedescendant on the listbox root.
rolegetOptionProps(id)Always "option".
tabindexgetOptionProps(id)"0" for the active option (roving-tabindex) or "-1" for all options (aria-activedescendant strategy).
aria-selectedgetOptionProps(id)"true" when the option is selected; "false" otherwise.
aria-disabledgetOptionProps(id)"true" when [disabled] is present; omitted otherwise.
aria-setsizegetOptionProps(id)Total number of options in the listbox (supports virtual scrolling).
aria-posinsetgetOptionProps(id)1-based position of this option within the full option list.
data-activegetOptionProps(id)Present when the option is the active option; used as a CSS hook.

These attributes must not be set directly on cv-option by the consumer. They are owned entirely by the parent and will be overwritten on each render cycle.

ChromVoid UIKit documentation