Skip to content

cv-window-splitter

A resizable pane separator that lets users drag or keyboard-navigate to redistribute space between two adjacent panels.

Headless: createWindowSplitter

Usage

View source
html
<div class="window-splitter-demo-shell" data-demo="window-splitter" data-live-demo-height="760">
  <section class="window-splitter-demo-hero" aria-labelledby="window-splitter-demo-title">
    <div class="window-splitter-demo-copy">
      <span class="window-splitter-demo-kicker">Resizable workspace boundary</span>
      <h3 id="window-splitter-demo-title">
        Resize visible and hidden panes without losing the separator contract.
      </h3>
      <p>
        Window splitter keeps pointer drag, keyboard resizing, snap points, ARIA separator values, and
        committed change events on the same headless model.
      </p>
    </div>

    <dl class="window-splitter-demo-metrics" aria-label="Window splitter behavior summary">
      <div>
        <dt>Role</dt>
        <dd>separator</dd>
      </div>
      <div>
        <dt>Keys</dt>
        <dd>arrows / Home / End</dd>
      </div>
      <div>
        <dt>Snap</dt>
        <dd>25 / 50 / 75</dd>
      </div>
    </dl>
  </section>

  <section class="window-splitter-demo-workbench" aria-labelledby="window-splitter-demo-workbench-title">
    <div class="window-splitter-demo-section-header">
      <span class="window-splitter-demo-kicker">Vault review workspace</span>
      <h4 id="window-splitter-demo-workbench-title">
        Drag the divider or focus it and use keyboard commands to rebalance the panes
      </h4>
    </div>

    <div class="window-splitter-demo-toolbar" aria-label="Active splitter capabilities">
      <span>orientation="vertical"</span>
      <span>snap="25% 50% 75%"</span>
      <span>cv-input + cv-change</span>
      <span>custom handle</span>
    </div>

    <div class="window-splitter-demo-stage">
      <cv-window-splitter
        id="window-splitter-demo-vault"
        orientation="vertical"
        position="50"
        min="0"
        max="100"
        step="5"
        snap="25% 50% 75%"
        snap-threshold="6"
        aria-label="Resize vault review panes"
      >
        <section class="window-splitter-demo-pane window-splitter-demo-pane--primary" slot="primary">
          <div class="window-splitter-demo-pane-header">
            <span>Visible surface</span>
            <strong>Travel profile</strong>
          </div>

          <div class="window-splitter-demo-card-list" aria-label="Visible records">
            <article>
              <span>Identity bundle</span>
              <strong>Inspectable</strong>
            </article>
            <article>
              <span>Border notes</span>
              <strong>Decoy layer</strong>
            </article>
            <article>
              <span>Session receipt</span>
              <strong>Expires today</strong>
            </article>
          </div>
        </section>

        <section class="window-splitter-demo-pane window-splitter-demo-pane--secondary" slot="secondary">
          <div class="window-splitter-demo-pane-header">
            <span>Sealed core</span>
            <strong>Hidden vault route</strong>
          </div>

          <div class="window-splitter-demo-proof-grid" aria-label="Hidden vault proof points">
            <div>
              <span>Namespace</span>
              <strong>deniable</strong>
            </div>
            <div>
              <span>Trust boundary</span>
              <strong>hardware</strong>
            </div>
            <div>
              <span>Reveal policy</span>
              <strong>local only</strong>
            </div>
            <div>
              <span>Audit state</span>
              <strong>verified</strong>
            </div>
          </div>
        </section>

        <span class="window-splitter-demo-handle" slot="separator" aria-hidden="true">
          <span></span>
          <span></span>
          <span></span>
        </span>
      </cv-window-splitter>
    </div>

    <div class="window-splitter-demo-status">
      <div>
        <output
          class="window-splitter-demo-readout"
          for="window-splitter-demo-vault"
          aria-live="polite"
          data-splitter-output
        >
          Primary pane: 50% | Event: ready
        </output>
        <meter
          class="window-splitter-demo-meter"
          min="0"
          max="100"
          value="50"
          aria-label="Primary pane size"
          data-splitter-meter
        ></meter>
      </div>

      <div class="window-splitter-demo-snaps" aria-label="Snap point guide">
        <span data-snap-value="25">25</span>
        <span data-snap-value="50">50</span>
        <span data-snap-value="75">75</span>
      </div>
    </div>
  </section>

  <section class="window-splitter-demo-secondary" aria-labelledby="window-splitter-demo-fixed-title">
    <div class="window-splitter-demo-section-header">
      <span class="window-splitter-demo-kicker">Fixed toggle mode</span>
      <h4 id="window-splitter-demo-fixed-title">
        Use fixed when the separator switches between two committed states
      </h4>
    </div>

    <cv-window-splitter
      id="window-splitter-demo-fixed"
      class="window-splitter-demo-fixed"
      orientation="horizontal"
      fixed
      position="35"
      min="0"
      max="100"
      aria-label="Toggle session notes panel"
    >
      <section class="window-splitter-demo-pane window-splitter-demo-pane--compact" slot="primary">
        <span>Session notes</span>
        <strong>Visible summary stays available while the lower panel toggles.</strong>
      </section>
      <section class="window-splitter-demo-pane window-splitter-demo-pane--compact" slot="secondary">
        <span>Audit detail</span>
        <strong>Focus the separator and press Enter to collapse or restore.</strong>
      </section>
    </cv-window-splitter>

    <output
      class="window-splitter-demo-readout"
      for="window-splitter-demo-fixed"
      aria-live="polite"
      data-fixed-output
    >
      Fixed pane: 35% | Press Enter on separator
    </output>
  </section>
</div>

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

      customElements.whenDefined('cv-window-splitter').then(async () => {
        const mainSplitter = shell.querySelector('#window-splitter-demo-vault')
        const fixedSplitter = shell.querySelector('#window-splitter-demo-fixed')
        const mainOutput = shell.querySelector('[data-splitter-output]')
        const fixedOutput = shell.querySelector('[data-fixed-output]')
        const meter = shell.querySelector('[data-splitter-meter]')
        const snapMarkers = [...shell.querySelectorAll('[data-snap-value]')]
        if (!mainSplitter || !fixedSplitter || !mainOutput || !fixedOutput || !meter) return

        const formatPosition = (position) => `${Math.round(Number(position))}%`

        const syncMain = (eventName = 'ready', nextPosition = Number(mainSplitter.position)) => {
          const position = Number(nextPosition)
          mainOutput.textContent = `Primary pane: ${formatPosition(position)} | Event: ${eventName}`
          meter.value = String(position)
          snapMarkers.forEach((marker) => {
            const snap = Number(marker.dataset.snapValue)
            marker.toggleAttribute('data-active', Math.abs(position - snap) <= 3)
          })
        }

        const syncFixed = (eventName = 'ready', nextPosition = Number(fixedSplitter.position)) => {
          fixedOutput.textContent = `Fixed pane: ${formatPosition(nextPosition)} | Event: ${eventName}`
        }

        let mainSyncFrame = 0
        let mainPendingPosition = Number(mainSplitter.position)
        let fixedSyncFrame = 0
        let fixedPendingPosition = Number(fixedSplitter.position)

        const readEventPosition = (event, splitter) => Number(event.detail?.position ?? splitter.position)

        const scheduleMainSync = (event) => {
          mainPendingPosition = readEventPosition(event, mainSplitter)
          if (mainSyncFrame) return
          mainSyncFrame = requestAnimationFrame(() => {
            mainSyncFrame = 0
            syncMain('cv-input', mainPendingPosition)
          })
        }

        const scheduleFixedSync = (event) => {
          fixedPendingPosition = readEventPosition(event, fixedSplitter)
          if (fixedSyncFrame) return
          fixedSyncFrame = requestAnimationFrame(() => {
            fixedSyncFrame = 0
            syncFixed('cv-input', fixedPendingPosition)
          })
        }

        mainSplitter.addEventListener('cv-input', scheduleMainSync)
        mainSplitter.addEventListener('cv-change', (event) => {
          if (mainSyncFrame) {
            cancelAnimationFrame(mainSyncFrame)
            mainSyncFrame = 0
          }
          syncMain('cv-change', readEventPosition(event, mainSplitter))
        })
        fixedSplitter.addEventListener('cv-input', scheduleFixedSync)
        fixedSplitter.addEventListener('cv-change', (event) => {
          if (fixedSyncFrame) {
            cancelAnimationFrame(fixedSyncFrame)
            fixedSyncFrame = 0
          }
          syncFixed('cv-change', readEventPosition(event, fixedSplitter))
        })

        await Promise.all([mainSplitter.updateComplete, fixedSplitter.updateComplete])
        syncMain()
        syncFixed()
      })
    })
</script>

Anatomy

<cv-window-splitter> (host)
└── <div part="base" data-orientation="vertical|horizontal">
    ├── <div part="pane" data-pane="primary" data-orientation="vertical|horizontal">
    │   └── <slot name="primary">
    ├── <div part="separator" role="separator" tabindex="0"
    │        aria-valuenow aria-valuemin aria-valuemax
    │        aria-orientation aria-controls
    │        data-orientation="vertical|horizontal">
    │   └── <span part="separator-handle">
    │       └── <slot name="separator">   ← custom handle content
    └── <div part="pane" data-pane="secondary" data-orientation="vertical|horizontal">
        └── <slot name="secondary">

The separator-handle span renders a default glyph ( for vertical, for horizontal) when the separator slot is empty.

Attributes

AttributeTypeDefaultDescription
positionNumber50Current splitter position within [min, max]. Reflected as an attribute.
minNumber0Minimum allowed position (inclusive).
maxNumber100Maximum allowed position (inclusive).
stepNumber1Step size applied per arrow-key press.
orientationString"horizontal"Axis of the separator bar: "vertical" (vertical bar, left/right split) | "horizontal" (horizontal bar, top/bottom split). Matches aria-orientation.
fixedBooleanfalseEnables fixed (toggle) mode. Arrow keys are disabled; Enter toggles position between min and max.
snapStringSpace-separated snap positions, e.g. "25 50 75" or "25% 50% 75%". Values ending in % are resolved as percentages of the [min, max] range. Snap logic runs inside headless setPosition.
snap-thresholdNumber12Maximum distance from a snap point within which setPosition snaps instead of using the raw value. Expressed in the same unit as position.
aria-labelString""Accessible label applied to the separator element.
aria-labelledbyString""ID(s) of element(s) that label the separator.

Variants

VariantAttributeDescription
Default(none)Continuous slider; arrow keys adjust position by step.
FixedfixedToggle mode; Enter collapses/restores. Arrow keys are disabled.

orientation is a configuration attribute, not a visual variant — both orientations share the same variant rows above.

Slots

SlotDescription
primaryContent of the primary (first) pane.
secondaryContent of the secondary (second) pane.
separatorCustom handle content rendered inside [part="separator-handle"]. Replaces the default orientation glyph when provided.

CSS Parts

PartElementDescription
base<div>Root grid container. Receives data-orientation and inherits the host-owned --cv-window-splitter-primary-size variable.
pane<div>Either pane. Carries data-pane="primary" or data-pane="secondary", and data-orientation.
separator<div>The focusable, interactive separator element with role="separator". Receives all ARIA and data-orientation attributes.
separator-handle<span>Visual drag handle inside the separator. Renders the default glyph or the separator slot content.

Data attributes on [part="pane"]

Data attributeValuesDescription
data-pane"primary" | "secondary"Identifies which pane this element is.
data-orientation"vertical" | "horizontal"Mirrors the host orientation attribute for CSS targeting.

Data attributes on [part="separator"]

Data attributeValuesDescription
data-orientation"vertical" | "horizontal"Mirrors the host orientation attribute for cursor and layout CSS.
data-draggingPresent when draggingSet while a pointer drag is active (set on pointerdown, removed on pointerup/pointercancel).

CSS Custom Properties

Component properties

PropertyDefaultDescription
--cv-window-splitter-primary-size50%Computed percentage size of the primary pane, set by the component on the host as the grid track size. Updated during drag and keyboard interaction.
--cv-window-splitter-divider-size8pxWidth (vertical orientation) or height (horizontal orientation) of the separator track in the grid layout.

Theme tokens consumed (via fallback values)

Theme propertyDefaultDescription
--cv-color-surface#141923Used (mixed with black) for the separator background.
--cv-color-border#2a3245Separator border color.
--cv-color-text-muted#9aa6bfSeparator handle icon color.
--cv-color-primary#65d7ffFocus ring color on the separator.

Visual States

SelectorDescription
[part="base"][data-orientation="vertical"]Vertical separator bar; [part="base"] uses grid-template-columns with three tracks (primary size, divider size, 1fr); cursor on separator is col-resize.
[part="base"][data-orientation="horizontal"]Horizontal separator bar; [part="base"] uses grid-template-rows with three tracks (primary size, divider size, 1fr); cursor on separator is row-resize.
:host([fixed])Fixed/toggle mode. No visual difference by default; host styles may suppress drag-cursor or reduce separator opacity as desired. (fixed is a reflected boolean property, so :host([fixed]) is a valid CSS hook for consumers.)
[part="separator"]:focus-visibleoutline: 2px solid var(--cv-color-primary, #65d7ff) with outline-offset: 1px.
[part="separator"][data-dragging]Applied while a pointer drag is in progress. Can be targeted in CSS for active drag styles (e.g. highlight, elevated z-index).

Reactive State Mapping

cv-window-splitter is a visual adapter over headless createWindowSplitter.

UIKit attributes → headless options / actions

UIKit AttributeDirectionHeadless Binding
positionattr → actionactions.setPosition(value) on change
minattr → optioncreateWindowSplitter({ min }) (model recreated)
maxattr → optioncreateWindowSplitter({ max }) (model recreated)
stepattr → optioncreateWindowSplitter({ step }) (model recreated)
orientationattr → optioncreateWindowSplitter({ orientation }) (model recreated)
fixedattr → optioncreateWindowSplitter({ isFixed }) (model recreated)
snapattr → optioncreateWindowSplitter({ snap }) (model recreated)
snap-thresholdattr → optioncreateWindowSplitter({ snapThreshold }) (model recreated)
aria-labelattr → optioncreateWindowSplitter({ ariaLabel }) (model recreated)
aria-labelledbyattr → optioncreateWindowSplitter({ ariaLabelledBy }) (model recreated)

Model recreation: when any option-only attribute changes, the entire headless model is recreated via createWindowSplitter(...) with the latest values.

Headless state → DOM reflection

Headless SignalDirectionDOM / CSS Reflection
state.position()state → CSS--cv-window-splitter-primary-size on the host; position host attribute updated
state.isDragging()state → attr[data-dragging] on [part="separator"]
state.orientation()state → attrdata-orientation on [part="base"], [part="separator"], and both [part="pane"] elements

Contract spreading

contracts.getSplitterProps() is spread onto [part="separator"] to apply:

  • role="separator"
  • tabindex="0"
  • aria-valuenow, aria-valuemin, aria-valuemax, aria-valuetext (if formatValueText provided)
  • aria-orientation
  • aria-controls (space-separated IDs of both pane elements)
  • aria-label / aria-labelledby (when set)
  • onKeyDown handler bound to the keydown event

contracts.getPrimaryPaneProps() is spread onto the primary [part="pane"] to apply id, data-pane="primary", and data-orientation.

contracts.getSecondaryPaneProps() is spread onto the secondary [part="pane"] to apply id, data-pane="secondary", and data-orientation.

Pointer event drag contract (target state)

The implementation MUST use pointer events with capture for reliable cross-boundary dragging:

  1. pointerdown on [part="separator"] (primary button only):

    • Call event.preventDefault() and focus the separator.
    • Call actions.startDragging().
    • Call separatorEl.setPointerCapture(event.pointerId) so subsequent pointermove/pointerup events are routed to the separator regardless of cursor position.
    • Set [data-dragging] on the separator.
    • Compute and apply the initial position via actions.setPosition(...).
  2. pointermove on [part="separator"] (while captured):

    • Convert event.clientX / event.clientY to a position value relative to [part="base"] bounding rect, clamped to [0, 1] ratio.
    • Call actions.setPosition(computedValue) (snap logic applied inside headless).
    • Dispatch cv-input event with { position }.
  3. pointerup / pointercancel on [part="separator"]:

    • Compute final position.
    • Call actions.stopDragging().
    • Call separatorEl.releasePointerCapture(event.pointerId).
    • Remove [data-dragging] from the separator.
    • If position changed during the drag, dispatch cv-change event with { position }.

Note: The current implementation uses mousedown/mousemove/mouseup on document. The target state described above replaces this with pointer capture on the separator element. IMPL_UIKIT will perform this migration.

Events

EventDetailDescription
cv-input{ position: number }Fires on every position change during drag (pointermove) or keyboard interaction. Bubbles and is composed.
cv-change{ position: number }Fires on committed changes: keyboard key release that caused a position change, or pointerup when position changed during the drag. Bubbles and is composed.

Both events are dispatched as CustomEvent with bubbles: true and composed: true. The cv-input event fires continuously during interaction; cv-change fires once per committed gesture.

ChromVoid UIKit documentation