Skip to content

cv-listbox ​

Standalone listbox widget for single or multiple selection from a list of options, with keyboard navigation, typeahead, optional grouping, and virtual scroll support.

Headless: createListbox

Usage ​

The demo keeps the headless contract visible: active focus, selected values, group labels, range selection, and alternate focus/orientation modes are shown in one operational surface. For option label composition, prefix/suffix slots, and rich option content, see cv-option.

View source
html
<div class="listbox-demo-shell" data-demo="listbox" data-live-demo-height="860">
  <section class="listbox-demo-hero" aria-labelledby="listbox-demo-title">
    <div class="listbox-demo-copy">
      <span class="listbox-demo-kicker">Collection selection primitive</span>
      <h3 id="listbox-demo-title">Choose one visible route, then compose the export fields.</h3>
      <p>
        <code>cv-listbox</code> adapts the headless model into DOM focus, ARIA state, grouped options, and
        keyboard selection without owning the visual content inside each option.
      </p>
    </div>

    <dl class="listbox-demo-metrics" aria-label="Listbox contract summary">
      <div>
        <dt>Default focus</dt>
        <dd><code>aria-activedescendant</code> on the listbox root</dd>
      </div>
      <div>
        <dt>Selection modes</dt>
        <dd>single, multiple, optional range</dd>
      </div>
      <div>
        <dt>Keyboard</dt>
        <dd>arrows, Home/End, typeahead, Space/Enter</dd>
      </div>
    </dl>
  </section>

  <section class="listbox-demo-workbench" aria-labelledby="listbox-demo-workbench-title">
    <div class="listbox-demo-section-header">
      <span class="listbox-demo-kicker">Interactive contract</span>
      <h4 id="listbox-demo-workbench-title">
        Grouped single-select and multi-select examples share one event readout.
      </h4>
    </div>

    <div class="listbox-demo-layout">
      <article
        class="listbox-demo-panel listbox-demo-panel--primary"
        aria-labelledby="listbox-demo-route-title"
      >
        <header class="listbox-demo-panel-header">
          <div>
            <span class="listbox-demo-label">Single select with groups</span>
            <h5 id="listbox-demo-route-title">Visible profile route</h5>
          </div>
          <cv-badge variant="primary">active root</cv-badge>
        </header>

        <cv-listbox class="listbox-demo-primary" aria-label="Visible vault route">
          <cv-listbox-group label="Visible surfaces">
            <cv-option value="daily" data-label="Daily vault">
              <span slot="prefix" class="listbox-demo-glyph">D</span>
              <span class="listbox-demo-option-copy">
                <strong>Daily vault</strong>
                <small>Default work profile</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="listbox-demo-glyph listbox-demo-glyph--violet">T</span>
              <span class="listbox-demo-option-copy">
                <strong>Travel profile</strong>
                <small>Deniable border route</small>
              </span>
              <cv-badge slot="suffix" variant="primary">visible</cv-badge>
            </cv-option>
          </cv-listbox-group>
          <cv-listbox-group label="Hidden layers">
            <cv-option value="sealed" data-label="Sealed core">
              <span slot="prefix" class="listbox-demo-glyph">S</span>
              <span class="listbox-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="remote" data-label="Remote recovery" disabled>
              <span slot="prefix" class="listbox-demo-glyph listbox-demo-glyph--muted">R</span>
              <span class="listbox-demo-option-copy">
                <strong>Remote recovery</strong>
                <small>Disabled until quorum returns</small>
              </span>
              <cv-badge slot="suffix" variant="neutral">disabled</cv-badge>
            </cv-option>
          </cv-listbox-group>
        </cv-listbox>
      </article>

      <article class="listbox-demo-panel" aria-labelledby="listbox-demo-export-title">
        <header class="listbox-demo-panel-header">
          <div>
            <span class="listbox-demo-label">Multiple select with range</span>
            <h5 id="listbox-demo-export-title">Export field set</h5>
          </div>
          <cv-badge variant="neutral">Shift + Arrow</cv-badge>
        </header>

        <cv-listbox selection-mode="multiple" range-selection aria-label="Export fields">
          <cv-option value="name" data-label="Record name" selected>
            <span slot="prefix" class="listbox-demo-glyph">N</span>
            <span class="listbox-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="listbox-demo-glyph">O</span>
            <span class="listbox-demo-option-copy">
              <strong>Owner</strong>
              <small>Operational accountability</small>
            </span>
          </cv-option>
          <cv-option value="risk" data-label="Risk flag">
            <span slot="prefix" class="listbox-demo-glyph listbox-demo-glyph--violet">R</span>
            <span class="listbox-demo-option-copy">
              <strong>Risk flag</strong>
              <small>Visible in elevated review</small>
            </span>
          </cv-option>
          <cv-option value="raw" data-label="Raw secret" disabled>
            <span slot="prefix" class="listbox-demo-glyph listbox-demo-glyph--muted">X</span>
            <span class="listbox-demo-option-copy">
              <strong>Raw secret</strong>
              <small>Never exported from this route</small>
            </span>
          </cv-option>
        </cv-listbox>
      </article>
    </div>

    <output class="listbox-demo-readout" aria-live="polite" data-listbox-readout>
      Selection events appear here.
    </output>

    <div class="listbox-demo-mode-grid" aria-label="Listbox mode examples">
      <article class="listbox-demo-mode" aria-labelledby="listbox-demo-horizontal-title">
        <span class="listbox-demo-label">Horizontal orientation</span>
        <h5 id="listbox-demo-horizontal-title">Review lane</h5>
        <cv-listbox orientation="horizontal" aria-label="Review lane">
          <cv-option value="all" data-label="All" selected>All</cv-option>
          <cv-option value="paired" data-label="Paired">Paired</cv-option>
          <cv-option value="blocked" data-label="Blocked">Blocked</cv-option>
        </cv-listbox>
      </article>

      <article class="listbox-demo-mode" aria-labelledby="listbox-demo-roving-title">
        <span class="listbox-demo-label">Roving tabindex</span>
        <h5 id="listbox-demo-roving-title">Direct option focus</h5>
        <cv-listbox focus-strategy="roving-tabindex" aria-label="Review depth">
          <cv-option value="summary" data-label="Summary" selected>Summary</cv-option>
          <cv-option value="proof" data-label="Proof">Proof</cv-option>
          <cv-option value="events" data-label="Events">Events</cv-option>
        </cv-listbox>
      </article>
    </div>
  </section>
</div>

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

    const readout = shell.querySelector('[data-listbox-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('.listbox-demo-primary')
      const fields = shell.querySelector('cv-listbox[selection-mode="multiple"]')
      const routeActive = getLabel(route?.querySelector('cv-option[data-active="true"]'))
      const routeSelected = getSelectedLabels(route)[0] || 'none'
      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-listbox> (host)
└── <div part="base" role="listbox">
    └── <slot>   ← accepts <cv-option> and <cv-listbox-group> children

Attributes ​

AttributeTypeDefaultDescription
selection-modeString"single"Selection mode: "single" | "multiple"
orientationString"vertical"Layout orientation: "vertical" | "horizontal"
focus-strategyString"aria-activedescendant"Focus management: "aria-activedescendant" | "roving-tabindex"
selection-follows-focusBooleanfalseAuto-select focused option in single mode
range-selectionBooleanfalseEnable Shift+Arrow and Shift+Space range selection (multiple mode only)
typeaheadBooleantrueEnable typeahead character navigation
aria-labelString""Accessible label for the listbox

Non-reflected properties:

PropertyTypeDefaultDescription
valuestring | nullnullFirst selected option value (single-select shorthand)
selectedValuesstring[][]Array of all selected option values

Slots ​

SlotDescription
(default)One or more <cv-option> or <cv-listbox-group> children

CSS Parts ​

PartElementDescription
base<div>Root listbox element with role="listbox"

CSS Custom Properties ​

PropertyDefaultDescription
--cv-listbox-gapvar(--cv-space-1, 4px)Gap between options
--cv-listbox-paddingvar(--cv-space-1, 4px)Inner padding of the listbox container
--cv-listbox-border-radiusvar(--cv-radius-md, 10px)Border radius of the listbox container
--cv-listbox-border-colorvar(--cv-color-border, #2a3245)Border color
--cv-listbox-backgroundvar(--cv-color-surface, #141923)Background color
--cv-listbox-focus-outline-colorvar(--cv-color-primary, #65d7ff)Focus-visible outline color

Visual States ​

Host selectorDescription
:host([orientation="horizontal"])Horizontal layout (flexbox row direction)
:host([selection-mode="multiple"])Multiple selection mode active
:host([focus-strategy="roving-tabindex"])Options receive DOM focus directly

Events ​

EventDetailDescription
cv-input{selectedValues: string[], activeValue: string | null}Fires when active option or selection changes via user interaction
cv-change{selectedValues: string[], activeValue: string | null}Fires when selected option(s) change via user interaction

Keyboard Interaction ​

All keyboard handling is delegated to headless actions.handleKeyDown. The following is the resulting behavior:

KeyContextAction
ArrowDown / ArrowRight*anyMove to next enabled option
ArrowUp / ArrowLeft*anyMove to previous enabled option
HomeanyMove to first enabled option
EndanyMove to last enabled option
Space / Entersingle modeSelect active option exclusively
Space / Entermultiple modeToggle active option selection
EscapeanyClose (for composite patterns)
Ctrl/Cmd + Amultiple modeSelect all enabled options
Shift + Arrowmultiple + range-selectionExtend range selection
Shift + Spacemultiple + range-selectionSelect range from anchor to active
printable chartypeahead enabledTypeahead navigation to matching option

*Arrow key mapping depends on orientation: vertical uses Up/Down, horizontal uses Left/Right.

Reactive State Mapping ​

cv-listbox is a visual adapter over headless createListbox.

Attribute to Headless (UIKit -> Headless) ​

UIKit PropertyDirectionHeadless Binding
selection-modeattr -> optionpassed as selectionMode in createListbox(options)
orientationattr -> optionpassed as orientation in createListbox(options)
focus-strategyattr -> optionpassed as focusStrategy in createListbox(options)
selection-follows-focusattr -> optionpassed as selectionFollowsFocus in createListbox(options)
range-selectionattr -> optionpassed as rangeSelection in createListbox(options)
typeaheadattr -> optionpassed as typeahead in createListbox(options)
aria-labelattr -> optionpassed as ariaLabel in createListbox(options)
value (setter)prop -> actionactions.selectOnly(id) / actions.clearSelected()

When any configuration attribute changes, the headless model is rebuilt via createListbox with updated options, preserving current selection and active state where still valid.

Headless to DOM (Headless -> UIKit) ​

Headless StateDirectionDOM Reflection
state.activeId()state -> renderaria-activedescendant on [part="base"] (activedescendant strategy); DOM focus on active option (roving-tabindex strategy)
state.selectedIds()state -> render[aria-selected] on each cv-option; selectedValues property; value property
state.selectionModestate -> attr[selection-mode] host attribute
state.focusStrategystate -> attr[focus-strategy] host attribute
state.orientationstate -> attr[orientation] host attribute
state.optionCountstate -> renderaria-setsize on each option via getOptionProps

Contract Spreading ​

  • contracts.getRootProps() is spread onto [part="base"] -- applies role, tabindex, aria-orientation, aria-label, aria-multiselectable, aria-activedescendant
  • contracts.getOptionProps(id) is spread onto each cv-option -- applies id, role, tabindex, aria-selected, aria-disabled, aria-setsize, aria-posinset, data-active
  • contracts.getGroupProps(groupId) is spread onto each cv-listbox-group shadow root group container -- applies id, role, aria-labelledby
  • contracts.getGroupLabelProps(groupId) is spread onto each group label element -- applies id, role
  • contracts.getGroupOptions(groupId) drives which options render within a group
  • contracts.getUngroupedOptions() drives which options render outside any group

UIKit-Only Concerns (NOT in headless) ​

  • Option visual styling (active highlight, selected highlight, disabled opacity)
  • Group visual styling (label header, indentation)
  • Virtual scroll viewport management and option recycling
  • cv-input and cv-change event dispatch based on state diffing after user interactions
  • preventDefault on navigation keys to prevent page scroll
  • Slot change detection to rebuild the headless model when child options are added/removed

Behavioral Contract ​

Option Collection ​

  • cv-listbox scans its light DOM children (direct cv-option and cv-option within cv-listbox-group) to build the options array for the headless model
  • Each cv-option must have a value attribute; if omitted, an auto-generated fallback option-{n} is assigned
  • The textContent of each cv-option is used as the option label for typeahead matching
  • Initial selection is read from cv-option[selected] attributes at first render
  • On slotchange, the model is rebuilt with the updated option list, preserving still-valid selection and active state

Pointer Interaction ​

  • Clicking a cv-option calls actions.setActive(id) followed by actions.selectOnly(id) (single) or actions.toggleSelected(id) (multiple)
  • Pointer interactions dispatch cv-input and cv-change events based on state diffing

Focus Management ​

  • When focus-strategy="aria-activedescendant" (default): [part="base"] has tabindex="0" and receives DOM focus; aria-activedescendant points to the active option; all options have tabindex="-1"
  • When focus-strategy="roving-tabindex": [part="base"] has tabindex="-1"; the active option has tabindex="0" and receives DOM focus; other options have tabindex="-1"

Virtual Scroll Support ​

  • aria-setsize and aria-posinset from getOptionProps support virtual scrolling
  • When using virtual scrolling, only a subset of options is rendered, but each carries correct setsize/posinset reflecting the full option list
  • Virtual scroll viewport management is a UIKit concern, not headless

Child Elements ​

cv-option ​

Individual selectable option within a cv-listbox or cv-listbox-group. The parent cv-listbox manages all ARIA attributes on this element via headless contracts.

Anatomy ​

<cv-option> (host)
└── <div part="base">
    └── <slot>

Attributes ​

AttributeTypeDefaultDescription
valueString""Unique identifier for this option. Auto-generated as option-{n} if omitted.
disabledBooleanfalseWhether the option is disabled
selectedBooleanfalseWhether the option is selected. Managed by parent.
activeBooleanfalseWhether the option is the active (highlighted) option. Managed by parent.

Slots ​

SlotDescription
(default)Option label content

CSS Parts ​

PartElementDescription
base<div>Root wrapper for the option content

Visual States ​

Host selectorDescription
:host([selected])Option is currently selected (primary tint at 34%)
:host([active])Option is the active/highlighted option (primary tint at 22%)
:host([disabled])Option is disabled (opacity 0.55)
:host(:focus-visible)Focus ring when option receives DOM focus (roving-tabindex strategy)

cv-listbox-group ​

Groups related options under a visible label header. Must be a direct child of cv-listbox.

Anatomy ​

<cv-listbox-group> (host)
├── <div part="label">   ← group label text
└── <slot>               ← accepts <cv-option> children

Attributes ​

AttributeTypeDefaultDescription
labelString""Visible group header text. Also used for aria-labelledby linkage via headless getGroupLabelProps.

Slots ​

SlotDescription
(default)One or more <cv-option> children

CSS Parts ​

PartElementDescription
label<div>Group label text element

CSS Custom Properties ​

PropertyDefaultDescription
--cv-listbox-group-label-colorvar(--cv-color-text-muted, #8892a6)Group label text color
--cv-listbox-group-label-font-size0.85emGroup label font size
--cv-listbox-group-gapvar(--cv-space-1, 4px)Gap between group label and options

Visual States ​

Host selectorDescription
:hostBlock display with group role and aria-labelledby

ChromVoid UIKit documentation