Skip to content

cv-toolbar

Container of interactive elements that provides a single tab stop and arrow-key navigation between items, with separator support and focus memory.

Headless: createToolbar

Usage

View source
html
<div class="toolbar-demo-shell" data-demo="toolbar">
  <section class="toolbar-demo-hero" aria-labelledby="toolbar-demo-title">
    <div class="toolbar-demo-copy">
      <span class="toolbar-demo-kicker">Composite command surface</span>
      <h3 id="toolbar-demo-title">Use toolbar when Tab should enter once and arrows move between tools.</h3>
      <p>
        Toolbar gives a cluster of peer commands one accessible surface:
        <code>role="toolbar"</code>, roving <code>tabindex</code>, arrow-key movement, separators, focus
        memory, and a controlled <code>value</code> for the active item.
      </p>
    </div>

    <dl class="toolbar-demo-metrics" aria-label="Toolbar behavior summary">
      <div>
        <dt>Tab stop</dt>
        <dd>one</dd>
      </div>
      <div>
        <dt>Keys</dt>
        <dd>arrows / Home / End</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>value + cv-change</dd>
      </div>
    </dl>
  </section>

  <section class="toolbar-demo-workbench" aria-labelledby="toolbar-demo-workbench-title">
    <div class="toolbar-demo-section-header">
      <span class="toolbar-demo-kicker">Record command strip</span>
      <h4 id="toolbar-demo-workbench-title">
        One toolbar can expose editing, inspection, and export tools without adding extra Tab stops
      </h4>
    </div>

    <div class="toolbar-demo-surface">
      <header class="toolbar-demo-surface-header">
        <div>
          <span class="toolbar-demo-label">Vault record</span>
          <strong>Border checkpoint profile</strong>
        </div>
        <output class="toolbar-demo-state" data-toolbar-active aria-live="polite">Command strip: Mask</output>
      </header>

      <cv-toolbar class="toolbar-demo-command-strip" value="item-1" wrap aria-label="Vault record tools">
        <cv-toolbar-item value="item-1">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">M</span>
            <span>Mask</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-item value="item-2">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">R</span>
            <span>Reveal</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-item value="item-3">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">C</span>
            <span>Copy</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-separator></cv-toolbar-separator>
        <cv-toolbar-item value="item-4">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">A</span>
            <span>Audit</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-item value="item-5" disabled>
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">E</span>
            <span>Export</span>
          </span>
        </cv-toolbar-item>
      </cv-toolbar>

      <div class="toolbar-demo-record" aria-label="Selected record preview">
        <div>
          <span>Surface</span>
          <strong>Travel profile</strong>
        </div>
        <div>
          <span>Visible state</span>
          <strong>masked</strong>
        </div>
        <div>
          <span>Audit trail</span>
          <strong>ready</strong>
        </div>
      </div>

      <output class="toolbar-demo-log" data-toolbar-log aria-live="polite">
        cv-change will report the active tool after keyboard or pointer movement.
      </output>
    </div>
  </section>

  <section class="toolbar-demo-split" aria-labelledby="toolbar-demo-rail-title">
    <div class="toolbar-demo-rail-panel">
      <div class="toolbar-demo-section-header">
        <span class="toolbar-demo-kicker">Vertical tool rail</span>
        <h4 id="toolbar-demo-rail-title">Use orientation for side tools and dense editors</h4>
      </div>

      <cv-toolbar
        class="toolbar-demo-rail"
        value="item-1"
        orientation="vertical"
        aria-label="Inspector tools"
      >
        <cv-toolbar-item value="item-1">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">I</span>
            <span>Inspect</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-item value="item-2">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">H</span>
            <span>History</span>
          </span>
        </cv-toolbar-item>
        <cv-toolbar-separator></cv-toolbar-separator>
        <cv-toolbar-item value="item-3">
          <span class="toolbar-demo-item">
            <span class="toolbar-demo-glyph" aria-hidden="true">P</span>
            <span>Policy</span>
          </span>
        </cv-toolbar-item>
      </cv-toolbar>
    </div>

    <div class="toolbar-demo-boundary">
      <span class="toolbar-demo-kicker">Boundary</span>
      <h4>Toolbar is not a button group.</h4>
      <div class="toolbar-demo-boundary-grid">
        <div>
          <span class="toolbar-demo-label">button-group</span>
          <cv-button-group attached aria-label="Record actions">
            <cv-button size="small" variant="primary">Unlock</cv-button>
            <cv-button size="small">Lock</cv-button>
            <cv-button size="small" variant="danger">Wipe</cv-button>
          </cv-button-group>
          <p>Use for independent buttons that stay normal buttons in the Tab order.</p>
        </div>

        <div>
          <span class="toolbar-demo-label">toolbar</span>
          <p>
            Use for peer tools inside one command surface, where Tab enters the surface once and arrows move
            between items.
          </p>
        </div>
      </div>
    </div>
  </section>
</div>

<script>
  document.querySelectorAll('.toolbar-demo-shell[data-demo="toolbar"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'
    const activeOutput = shell.querySelector('[data-toolbar-active]')
    const logOutput = shell.querySelector('[data-toolbar-log]')

    const labelForValue = (toolbar, value) => {
      const item = Array.from(toolbar.querySelectorAll('cv-toolbar-item')).find(
        (candidate) => candidate.value === value || candidate.getAttribute('value') === value,
      )
      return item?.querySelector('.toolbar-demo-item span:last-child')?.textContent?.trim() || value
    }

    const bindToolbar = (selector, label) => {
      const toolbar = shell.querySelector(selector)
      if (!toolbar) return

      toolbar.addEventListener('cv-change', (event) => {
        const value = event.detail?.activeId || toolbar.getAttribute('value') || 'none'
        const readableValue = labelForValue(toolbar, value)
        if (activeOutput) {
          activeOutput.textContent = `${label}: ${readableValue}`
        }
        if (logOutput) {
          logOutput.textContent = `cv-change -> activeId "${value}" (${readableValue})`
        }
      })
    }

    bindToolbar('.toolbar-demo-command-strip', 'Command strip')
    bindToolbar('.toolbar-demo-rail', 'Inspector rail')
  })
</script>

Anatomy

<cv-toolbar> (host)
└── <div part="base" role="toolbar" aria-orientation="…">
    └── <slot>                         ← accepts <cv-toolbar-item> and <cv-toolbar-separator> children

Attributes

AttributeTypeDefaultDescription
valueString""Active item value (reflects activeId from headless state)
orientationString"horizontal"Navigation axis: horizontal | vertical
wrapBooleantrueWhether arrow navigation wraps from last to first and vice versa. When false, clamps at boundaries.
aria-labelString""Accessible label for the toolbar

Slots

SlotDescription
(default)<cv-toolbar-item> and <cv-toolbar-separator> children

CSS Parts

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

CSS Custom Properties

PropertyDefaultDescription
--cv-toolbar-gapvar(--cv-space-1, 4px)Spacing between toolbar children
--cv-toolbar-paddingvar(--cv-space-1, 4px)Internal padding of the toolbar container
--cv-toolbar-border-radiusvar(--cv-radius-md, 10px)Border radius of the toolbar container

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

Theme PropertyDefaultDescription
--cv-color-border#2a3245Toolbar border color
--cv-color-surface#141923Toolbar background color
--cv-space-14pxGap and padding fallback
--cv-radius-md10pxBorder radius fallback

Visual States

Host selectorDescription
:host([orientation="vertical"])Flex direction switches to column; items stretch to fill width

Events

EventDetailDescription
cv-input{activeId: string | null}Fires on any user-driven active item change (arrow keys, click, focus)
cv-change{activeId: string | null}Fires when active item commits (same detail as cv-input; both fire together on navigation)

Both cv-input and cv-change fire when user interaction changes the active item. Programmatic value attribute changes that result in a headless state update also dispatch these events.

Reactive State Mapping

cv-toolbar is a visual adapter over headless createToolbar.

UIKit Property to Headless Binding

UIKit PropertyDirectionHeadless Binding
valueattr -> actionactions.setActive(value) when value attribute changes
orientationattr -> optionpassed as orientation in createToolbar(options)
wrapattr -> optionpassed as wrap in createToolbar(options)
aria-labelattr -> optionpassed as ariaLabel in createToolbar(options)

Headless State to DOM Reflection

Headless StateDirectionDOM Reflection
state.activeId()state -> attrcv-toolbar[value] host attribute
state.activeId()state -> attrcv-toolbar-item[active] boolean attribute on the active item element
state.activeId()state -> propcv-toolbar-item.tabIndex set to 0 for active, -1 for others

Headless Actions Called

ActionUIKit Trigger
actions.setActive(id)Item receives focus or click; also called when value attribute changes
actions.handleKeyDown(event)keydown event on the toolbar root [part="base"]
actions.handleToolbarFocus()focusin event on toolbar root when toolbar was not previously focused (re-entry detection)
actions.handleToolbarBlur()focusout event on toolbar root when relatedTarget is outside the toolbar (full blur detection)

Headless Contracts Spread

ContractUIKit Target
contracts.getRootProps()Spread onto the [part="base"] element (id, role, aria-orientation, aria-label)
contracts.getItemProps(id)Spread onto each cv-toolbar-item element (id, tabindex, aria-disabled, data-active). The onFocus callback is bound to the element's focus event.
contracts.getSeparatorProps(id)Spread onto each cv-toolbar-separator element (id, role, aria-orientation)

UIKit-Only Concerns (Not in Headless)

  • Focus management DOM calls: Calling .focus() on the DOM element matching activeId after keyboard navigation. Headless sets state; UIKit moves DOM focus.
  • Focus-in/focus-out tracking: UIKit must detect toolbar entry vs. internal focus moves (e.g., using a hasFocus flag updated on focusin/focusout with relatedTarget checks).
  • Separator rendering: Visual appearance of separators (line style, thickness, spacing) is a UIKit concern. Headless only provides ARIA props.
  • Slot change handling: Rebuilding the headless model when children are added or removed via slotchange.
  • cv-input / cv-change events: Custom DOM events dispatched by the UIKit wrapper, not part of the headless model.

UIKit does not own navigation or focus-memory logic; headless state is the source of truth.

Keyboard Interaction

KeyHorizontalVertical
ArrowRightMove to next item
ArrowLeftMove to previous item
ArrowDownMove to next item
ArrowUpMove to previous item
HomeMove to first itemMove to first item
EndMove to last itemMove to last item
  • Disabled items and separators are skipped during keyboard navigation.
  • When wrap is true (default), navigation wraps from last to first and vice versa.
  • When wrap is false, navigation clamps at boundaries (first/last item).
  • Roving tabindex: only the active item has tabindex="0"; all others have tabindex="-1".
  • Focus memory: re-entering the toolbar via Tab restores focus to the last-active item.

Child Elements

cv-toolbar-item

Interactive element within a toolbar that participates in roving tabindex navigation.

Anatomy

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

Attributes

AttributeTypeDefaultDescription
valueString""Unique identifier for this item. Auto-generated as item-{n} if empty.
disabledBooleanfalsePrevents this item from receiving focus via keyboard navigation
activeBooleanfalseWhether this item is the current roving-focus target (managed by parent)

Slots

SlotDescription
(default)Item content (text, icon, or any inline content)

CSS Parts

PartElementDescription
base<div>Root interactive wrapper

CSS Custom Properties

PropertyDefaultDescription
--cv-toolbar-item-min-height32pxMinimum block size of the item
--cv-toolbar-item-padding-inlinevar(--cv-space-3, 12px)Horizontal padding
--cv-toolbar-item-border-radiusvar(--cv-radius-sm, 6px)Border radius

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

Theme PropertyDefaultDescription
--cv-color-border#2a3245Item border color
--cv-color-surface#141923Item background color
--cv-color-text#e8ecf6Item text color
--cv-color-primary#65d7ffActive state accent color
--cv-duration-fast120msTransition duration
--cv-easing-standardeaseTransition timing function

Visual States

Host selectorDescription
:host([active])Primary-tinted border and blended background using --cv-color-primary
:host([disabled])Reduced opacity (0.55)
:host(:focus-visible)Focus ring: 2px solid var(--cv-color-primary) with 1px offset

cv-toolbar-separator

Non-interactive visual divider within a toolbar. Separators are skipped by keyboard navigation and cannot receive focus.

Anatomy

<cv-toolbar-separator> (host)
└── <div part="base" role="separator" aria-orientation="…">

The separator's aria-orientation is perpendicular to the toolbar's orientation: a horizontal toolbar renders vertical separators, and vice versa.

Attributes

AttributeTypeDefaultDescription
valueString""Identifier used to match the separator in the headless item list

CSS Parts

PartElementDescription
base<div>Separator line element with role="separator"

CSS Custom Properties

PropertyDefaultDescription
--cv-toolbar-separator-size1pxThickness of the separator line
--cv-toolbar-separator-colorvar(--cv-color-border, #2a3245)Color of the separator line
--cv-toolbar-separator-marginvar(--cv-space-1, 4px)Margin around the separator

Visual States

Host selectorDescription
:hostIn a horizontal toolbar: vertical line (height: auto, width: --cv-toolbar-separator-size). In a vertical toolbar: horizontal line (width: auto, height: --cv-toolbar-separator-size). Orientation is communicated via the parent spreading getSeparatorProps.

ChromVoid UIKit documentation