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.
<!-- 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
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight | Move divider 1% |
| Shift + Arrow | Move divider 10% |
| Home | Move to minimum |
| End | Move to maximum |
Layer 2: Web Component
Full component. Self-contained <split-surface> element with persistence, collapsible panels, custom events, and JS API.
<split-surface> <nav>Sidebar</nav> <main>Content</main></split-surface>
All Attributes
<split-surface direction="vertical" position="30" min="15" max="70" persist="editor-sidebar" collapsible> <aside>Panel A</aside> <section>Panel B</section></split-surface>
| Attribute | Values | Default | Description |
|---|---|---|---|
direction | "horizontal", "vertical" | horizontal | Split axis |
position | number | 50 | Split position as percentage (0–100) |
min | number | 10 | Minimum panel size percentage |
max | number | 90 | Maximum panel size percentage |
persist | string | — | localStorage key for position persistence |
collapsible | boolean | — | Double-click divider to collapse first panel |
Required Structure
| Element | Required | Description |
|---|---|---|
first child | yes | First panel content |
second child | yes | Second panel content — divider is auto-created between them |
Events
| Event | Detail | Description |
|---|---|---|
split-surface:resize | { position } | Fired when panel is resized |
split-surface:collapse | { collapsed } | Fired when panel is collapsed or expanded |
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 / Method | Type | Description |
|---|---|---|
element.position | number | Get/set current position (0-100) |
element.collapsed | boolean | Get/set collapsed state |
element.reset() | method | Restore 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 panelel.collapsed = false; // Expand el.reset(); // Restore to initial position/code-block </section> <section> <h2>Keyboard Navigation</h2> <p>Both layers support the same keyboard controls:</p> <table class="props-table"> <thead> <tr><th>Key</th><th>Action</th></tr> </thead> <tbody> <tr><td><kbd>ArrowLeft</kbd> / <kbd>ArrowRight</kbd></td><td>Move divider 1% (horizontal)</td></tr> <tr><td><kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd></td><td>Move divider 1% (vertical)</td></tr> <tr><td><kbd>Shift</kbd> + Arrow</td><td>Move divider 10%</td></tr> <tr><td><kbd>Home</kbd></td><td>Move to minimum position</td></tr> <tr><td><kbd>End</kbd></td><td>Move to maximum position</td></tr> </tbody> </table> </section> <section> <h2>Accessibility</h2> <h3>Separator Role</h3> <p>The divider has <code>role="separator"</code> with <code>aria-orientation</code>, <code>aria-valuenow</code>, <code>aria-valuemin</code>, and <code>aria-valuemax</code>. Screen readers announce the current position.</p> <h3>Keyboard Support</h3> <p>The divider is focusable (<code>tabindex="0"</code>) and responds to arrow keys. <kbd>Shift</kbd> + arrow provides larger steps. <kbd>Home</kbd> and <kbd>End</kbd> jump to min/max.</p> <h3>Touch Support</h3> <p>The divider uses <code>touch-action: none</code> and <code>setPointerCapture</code> for reliable pointer drag behavior across devices.</p> </section> <section> <h2>Styling</h2> <p>The divider uses the <code>.split-divider</code> class shared by both layers. The <code>::after</code> pseudo-element renders a grip indicator.</p> <code-block language="css" show-lines label="Custom divider styling" data-escape>/* 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
| Layer | JS | Keyboard | ARIA | Persistence | Best for |
|---|---|---|---|---|---|
data-splitter | ~80 lines | Yes | Yes | No | Enhanced layout containers with accessibility |
<split-surface> | ~120 lines | Yes | Yes | Yes | Full-featured standalone component |
Related
<compare-surface>— Before/after image comparison sliderdata-layout="sidebar"— CSS sidebar layout primitive<tab-set>— Tab panels for content switching