split-surface

Resizable panel splitter with two layers of progressive enhancement: attribute init and full web component.

Overview

The splitter provides resizable panel support as a two-layer progressive enhancement story — from attribute init to full web component — matching a "platform-first, enhance upward" philosophy.

Layer 1: Attribute Init

Light JS (~80 lines). Apply data-splitter to a container. A small init script injects a full-height drag divider between the first two children with pointer and keyboard events.

Main content area
<!-- Light JS — auto-injected divider --> <div data-layout="sidebar" data-splitter> <nav>Sidebar content</nav> <main>Main content</main> </div>

Vertical Splitting

<div data-layout="stack" data-splitter="vertical"> <div>Top panel</div> <div>Bottom panel</div> </div>

Constraints

Set data-min and data-max on children to constrain how far the divider can move.

<div data-layout="sidebar" data-splitter> <nav data-layout-min="20" data-layout-max="60">Sidebar (20-60%)</nav> <main>Main content</main> </div>

Keyboard

KeyAction
ArrowLeft / ArrowRightMove divider 1%
Shift + ArrowMove divider 10%
HomeMove to minimum
EndMove to maximum

Layer 2: Web Component

Full component. Self-contained <split-surface> element with persistence, collapsible panels, custom events, and JS API.

Main content panel
<split-surface> <nav>Sidebar</nav> <main>Content</main> </split-surface>

All Attributes

<split-surface data-direction="vertical" data-position="30" data-layout-min="15" data-layout-max="70" data-persist="editor-sidebar" data-collapsible> <aside>Panel A</aside> <section>Panel B</section> </split-surface>
AttributeValuesDefaultDescription
data-directionhorizontal, verticalhorizontalSplit direction
data-position0-10050Initial split position as percentage
data-min0-10010Minimum panel size (%)
data-max0-10090Maximum panel size (%)
data-persiststring keylocalStorage key for position persistence
data-collapsiblebooleanDouble-click divider to collapse first panel

Events

EventDetailDescription
split-surface:resize{ position: number }Fired when the divider position changes.
split-surface:collapse{ collapsed: boolean }Fired when panel is collapsed or expanded via double-click.
const splitter = document.querySelector('split-surface'); splitter.addEventListener('split-surface:resize', (e) => { console.log('Position:', e.detail.position); }); splitter.addEventListener('split-surface:collapse', (e) => { console.log('Collapsed:', e.detail.collapsed); });

JavaScript API

Property / MethodTypeDescription
element.positionnumberGet/set current position (0-100)
element.collapsedbooleanGet/set collapsed state
element.reset()methodRestore to initial position, clear persistence
const el = document.querySelector('split-surface'); el.position = 30; // Set position to 30% console.log(el.position); // Read current position el.collapsed = true; // Collapse first panel el.collapsed = false; // Expand el.reset(); // Restore to initial position

Keyboard Navigation

Both layers support the same keyboard controls:

KeyAction
ArrowLeft / ArrowRightMove divider 1% (horizontal)
ArrowUp / ArrowDownMove divider 1% (vertical)
Shift + ArrowMove divider 10%
HomeMove to minimum position
EndMove to maximum position

Accessibility

Separator Role

The divider has role="separator" with aria-orientation, aria-valuenow, aria-valuemin, and aria-valuemax. Screen readers announce the current position.

Keyboard Support

The divider is focusable (tabindex="0") and responds to arrow keys. Shift + arrow provides larger steps. Home and End jump to min/max.

Touch Support

The divider uses touch-action: none and setPointerCapture for reliable pointer drag behavior across devices.

Styling

The divider uses the .split-divider class shared by both layers. The ::after pseudo-element renders a grip indicator.

/* Custom divider color */ .split-divider { background: var(--color-interactive); } /* Custom grip handle */ .split-divider::after { background: white; border-radius: 4px; }

Progressive Enhancement

Without JavaScript, <split-surface> renders as a simple two-column flex layout. The :not(:defined) selector provides the fallback. Once JS registers the component, the divider appears.

/* Without JS: simple two-column flex */ split-surface:not(:defined) { display: flex; gap: var(--size-s); } split-surface:not(:defined) > * { flex: 1; min-width: 0; }

Which Layer to Choose

LayerJSKeyboardARIAPersistenceBest for
data-splitter~80 linesYesYesNoEnhanced layout containers with accessibility
<split-surface>~120 linesYesYesYesFull-featured standalone component

Related