Skip to content

cv-tabs

Tabbed interface for switching between related content panels. Use tabs when each panel is a peer view of the same object or workflow, not as a replacement for page navigation.

Headless: createTabs

Usage

View source
html
<div class="tabs-demo-shell" data-demo="tabs" data-live-demo-height="920">
  <section class="tabs-demo-hero" aria-labelledby="tabs-demo-title">
    <div class="tabs-demo-copy">
      <span class="tabs-demo-kicker">Headless selection contract</span>
      <h3 id="tabs-demo-title">One active view, one selected panel, no route change.</h3>
      <p>
        <code>cv-tabs</code> adapts the headless tabs model into a compact product surface: roving focus,
        selected panel reflection, automatic or manual activation, and optional close events.
      </p>
    </div>

    <dl class="tabs-demo-metrics" aria-label="Tabs behavior summary">
      <div>
        <dt>Selected</dt>
        <dd><code>value</code> mirrors headless state</dd>
      </div>
      <div>
        <dt>Keyboard</dt>
        <dd>Arrow keys move focus, Enter commits in manual mode</dd>
      </div>
      <div>
        <dt>Events</dt>
        <dd><code>cv-input</code> for active changes, <code>cv-change</code> for selection</dd>
      </div>
    </dl>
  </section>

  <section class="tabs-demo-workbench" aria-labelledby="tabs-demo-workbench-title">
    <div class="tabs-demo-section-header">
      <span class="tabs-demo-kicker">Record detail surface</span>
      <h4 id="tabs-demo-workbench-title">
        Horizontal tabs keep peer views attached to the same vault record.
      </h4>
    </div>

    <div class="tabs-demo-board">
      <cv-tabs value="overview" aria-label="Vault record tabs">
        <cv-tab slot="nav" value="overview">Overview</cv-tab>
        <cv-tab slot="nav" value="history">History</cv-tab>
        <cv-tab slot="nav" value="access">Access</cv-tab>
        <cv-tab slot="nav" value="recovery" disabled>Recovery</cv-tab>

        <cv-tab-panel tab="overview">
          <article class="tabs-demo-panel-card">
            <header>
              <span class="tabs-demo-label">Visible record</span>
              <strong>Gateway credentials</strong>
            </header>
            <dl class="tabs-demo-facts" aria-label="Overview facts">
              <div>
                <dt>Freshness</dt>
                <dd>Rotated 18 min ago</dd>
              </div>
              <div>
                <dt>Route</dt>
                <dd>Visible vault</dd>
              </div>
              <div>
                <dt>Status</dt>
                <dd>Ready</dd>
              </div>
            </dl>
          </article>
        </cv-tab-panel>
        <cv-tab-panel tab="history">
          <article class="tabs-demo-panel-card">
            <header>
              <span class="tabs-demo-label">Recent activity</span>
              <strong>Three committed changes</strong>
            </header>
            <ol class="tabs-demo-timeline" aria-label="Record history">
              <li>Access policy reviewed</li>
              <li>Password material rotated</li>
              <li>Audit export denied</li>
            </ol>
          </article>
        </cv-tab-panel>
        <cv-tab-panel tab="access">
          <article class="tabs-demo-panel-card">
            <header>
              <span class="tabs-demo-label">Access boundary</span>
              <strong>Two trusted devices</strong>
            </header>
            <dl class="tabs-demo-facts" aria-label="Access facts">
              <div>
                <dt>Desktop</dt>
                <dd>Paired</dd>
              </div>
              <div>
                <dt>Mobile</dt>
                <dd>Review</dd>
              </div>
            </dl>
          </article>
        </cv-tab-panel>
        <cv-tab-panel tab="recovery">
          <article class="tabs-demo-panel-card">
            <header>
              <span class="tabs-demo-label">Unavailable</span>
              <strong>Recovery is locked</strong>
            </header>
            <p>Disabled tabs stay visible when the state exists but cannot be selected yet.</p>
          </article>
        </cv-tab-panel>
      </cv-tabs>

      <aside class="tabs-demo-status" aria-label="Tabs event contract">
        <div>
          <span class="tabs-demo-label">Activation</span>
          <strong>Automatic</strong>
          <p>Clicking or arrowing to a tab updates active and selected state together.</p>
        </div>
        <div>
          <span class="tabs-demo-label">Panel contract</span>
          <strong>Hidden panels remain mounted</strong>
          <p>The adapter reflects <code>hidden</code>, <code>selected</code>, and ARIA ownership.</p>
        </div>
      </aside>
    </div>
  </section>

  <section class="tabs-demo-section" aria-labelledby="tabs-demo-vertical-title">
    <div class="tabs-demo-section-header">
      <span class="tabs-demo-kicker">Manual settings rail</span>
      <h4 id="tabs-demo-vertical-title">
        Vertical tabs work when focus preview and final selection are separate user decisions.
      </h4>
    </div>

    <cv-tabs value="policy" orientation="vertical" activation-mode="manual" aria-label="Vault policy tabs">
      <cv-tab slot="nav" value="policy">Threat model</cv-tab>
      <cv-tab slot="nav" value="devices">Trusted devices</cv-tab>
      <cv-tab slot="nav" value="exports">Export policy</cv-tab>

      <cv-tab-panel tab="policy">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Manual activation</span>
          <strong>Arrow keys preview focus first.</strong>
          <p>Press Enter or Space to commit the selected panel.</p>
        </div>
      </cv-tab-panel>
      <cv-tab-panel tab="devices">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Device review</span>
          <strong>Longer labels stay legible in a side rail.</strong>
          <p>Use vertical orientation for settings groups with stable local navigation.</p>
        </div>
      </cv-tab-panel>
      <cv-tab-panel tab="exports">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Export guard</span>
          <strong>Panels keep width stable across selected tabs.</strong>
          <p>The content area does not jump when tab labels differ in length.</p>
        </div>
      </cv-tab-panel>
    </cv-tabs>
  </section>

  <section class="tabs-demo-section tabs-demo-closable" aria-labelledby="tabs-demo-close-title">
    <div class="tabs-demo-section-header">
      <span class="tabs-demo-kicker">Closable workspace tabs</span>
      <h4 id="tabs-demo-close-title">
        Close buttons are opt-in and must be paired with consumer-owned removal.
      </h4>
    </div>

    <cv-tabs value="session" aria-label="Workspace tabs">
      <cv-tab slot="nav" value="session" closable>Session notes</cv-tab>
      <cv-tab slot="nav" value="audit" closable>Audit diff</cv-tab>
      <cv-tab slot="nav" value="policy" closable>Policy draft</cv-tab>

      <cv-tab-panel tab="session">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Consumer responsibility</span>
          <p>
            Handle <code>cv-close</code>, remove the matching <code>cv-tab</code> and
            <code>cv-tab-panel</code>, then let the rebuilt model choose the fallback selection.
          </p>
        </div>
      </cv-tab-panel>
      <cv-tab-panel tab="audit">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Temporary record</span>
          <p>Closable tabs fit removable workspaces, not ordinary settings groups.</p>
        </div>
      </cv-tab-panel>
      <cv-tab-panel tab="policy">
        <div class="tabs-demo-panel-card">
          <span class="tabs-demo-label">Temporary draft</span>
          <p>Keep close affordances explicit so basic tab selection remains calm.</p>
        </div>
      </cv-tab-panel>
    </cv-tabs>

    <cv-empty-state
      class="tabs-demo-empty"
      icon="folder-open"
      headline="All workspace tabs are closed"
      description="Temporary records were removed from this local workspace. Add a new record before continuing."
      hidden
    ></cv-empty-state>

    <script>
      document
        .querySelectorAll('.tabs-demo-closable cv-tabs:not([data-close-demo-ready])')
        .forEach((tabs) => {
          tabs.dataset.closeDemoReady = 'true'
          const section = tabs.closest('.tabs-demo-closable')
          const emptyState = section?.querySelector('.tabs-demo-empty')
          const updateEmptyState = () => {
            const isEmpty = tabs.querySelectorAll('cv-tab').length === 0
            tabs.hidden = isEmpty
            if (emptyState instanceof HTMLElement) {
              emptyState.hidden = !isEmpty
            }
          }

          tabs.addEventListener('cv-close', (event) => {
            const value = event.detail?.value
            if (!value) return

            const escaped = CSS.escape(value)
            tabs.querySelector(`cv-tab[value="${escaped}"]`)?.remove()
            tabs.querySelector(`cv-tab-panel[tab="${escaped}"]`)?.remove()
            updateEmptyState()
          })

          updateEmptyState()
        })
    </script>
  </section>
</div>

Anatomy

<cv-tabs> (host)
└── <div part="base">
    ├── <div part="list" role="tablist">
    │   ├── <slot name="nav">            ← accepts <cv-tab> children
    │   └── <div part="indicator">       ← animated active indicator
    └── <div part="panels">
        └── <slot>                         ← accepts <cv-tab-panel> children

Attributes

AttributeTypeDefaultDescription
valueString""Currently selected tab value
orientationString"horizontal"Layout: horizontal | vertical
activation-modeString"automatic"Activation: automatic | manual
aria-labelString""Accessible label for the tablist

Slots

SlotDescription
nav<cv-tab> children
(default)<cv-tab-panel> children

CSS Parts

PartElementDescription
base<div>Root layout container
list<div>Tablist wrapper
indicator<div>Animated active indicator positioned under the selected tab
panels<div>Panel container

CSS Custom Properties

PropertyDefaultDescription
--cv-tabs-indicator-colorvar(--cv-color-primary, #65d7ff)Color of the active indicator
--cv-tabs-indicator-size3pxIndicator thickness: height for horizontal orientation, width for vertical orientation

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

Theme PropertyDefaultDescription
--cv-space-14pxGap between tabs, list padding
--cv-space-28pxGap between list and panels
--cv-space-312pxPanels padding
--cv-radius-md10pxList and panels border radius
--cv-color-border#2a3245List and panels border
--cv-color-surface#141923List and panels background
--cv-color-primary#65d7ffFocus and selected accent color

Visual States

Host selectorDescription
:host([orientation="vertical"])Layout switches to vertical tablist + panel columns

Events

EventDetailDescription
cv-input{activeTabId: string | null, selectedTabId: string | null}Fires on any active or selected state change, including active-only changes that do not change selection
cv-change{activeTabId: string | null, selectedTabId: string | null}Fires when selected tab changes

cv-input fires on every user-driven state transition (active or selected). cv-change fires only when selectedTabId changes. Both events share the same detail shape. In manual activation mode, arrow-key navigation fires cv-input (active change) without cv-change; pressing Enter/Space fires both cv-input and cv-change.

Reactive State Mapping

cv-tabs is a visual adapter over headless createTabs reactive state.

UIKit Property to Headless Binding

UIKit PropertyDirectionHeadless Binding
valueattr → actionactions.select(value) when value attribute changes
orientationattr → optionpassed as orientation in createTabs(options)
activation-modeattr → optionpassed as activationMode in createTabs(options)
aria-labelattr → optionpassed as ariaLabel in createTabs(options)

Headless State to DOM Reflection

Headless StateDirectionDOM Reflection
state.selectedTabId()state → attrcv-tabs[value] host attribute
state.activeTabId()state → attrcv-tab[active] boolean attribute on the active tab element
state.selectedTabId()state → attrcv-tab[selected] boolean attribute on the selected tab element
state.selectedTabId()state → attrcv-tab-panel[selected] and cv-tab-panel[hidden] on panel elements

Headless Actions Called

ActionUIKit Trigger
actions.select(id)Tab is clicked or tapped (pointer activation)
actions.handleKeyDown(event)keydown event on a tab element

Headless Contracts Spread

ContractUIKit Target
contracts.getTabListProps()Spread onto [part="list"] element
contracts.getTabProps(id)Spread onto each cv-tab element (via attribute sync)
contracts.getPanelProps(id)Spread onto each cv-tab-panel element (via attribute sync)

UIKit-Only Concerns (Not in Headless)

  • Active indicator: Positioned and animated at the UIKit layer using selectedTabId to determine which tab to highlight.
  • Closable tabs: Close button rendering and close orchestration are UIKit concerns. Headless handles selection fallback implicitly through model rebuild with an updated tab list (without the closed tab).
  • cv-input / cv-change events: Custom DOM events dispatched by the UIKit wrapper, not part of the headless model.

UIKit does not own tab selection logic; headless state is the source of truth.

Child Elements

cv-tab

Individual tab trigger within the tablist.

Anatomy

<cv-tab> (host)
└── <div class="tab" part="base">
    ├── <slot>
    └── <button part="close-button">     ← only when [closable]

Attributes

AttributeTypeDefaultDescription
valueString""Unique identifier linking this tab to a panel
disabledBooleanfalsePrevents selection and keyboard activation
activeBooleanfalseWhether this tab has roving focus (managed by parent)
selectedBooleanfalseWhether this tab's panel is visible (managed by parent)
closableBooleanfalseShows close affordance for removal flows

Slots

SlotDescription
(default)Tab label content

CSS Parts

PartElementDescription
base<div>Tab interactive wrapper
close-button<button>Close affordance (rendered only when closable is true)

Visual States

Host selectorDescription
:host([active])Focused tab in roving tabindex model
:host([selected])Selected tab with visible panel
:host([disabled])Disabled appearance and non-interactive behavior

Events

EventDetailDescription
cv-close{value: string}Requests removal of this tab when close affordance is activated

The cv-close event bubbles and is composed. It is dispatched when the user activates the close button. The value in the detail corresponds to the tab's value attribute. The parent cv-tabs handles close orchestration: it determines a fallback tab, transitions selection if the closed tab was active or selected, and expects the consumer to remove the cv-tab and cv-tab-panel elements from the DOM.


cv-tab-panel

Content panel associated with a tab.

Anatomy

<cv-tab-panel> (host)
└── <div part="base" role="tabpanel">
    └── <slot>

Attributes

AttributeTypeDefaultDescription
tabString""Value of the associated <cv-tab>
selectedBooleanfalseWhether this panel is visible (managed by parent)

Slots

SlotDescription
(default)Panel content

CSS Parts

PartElementDescription
base<div>Panel content wrapper

Visual States

Host selectorDescription
:host([hidden])Hidden when panel is not selected

ChromVoid UIKit documentation