Split Pane
Resizable panes with draggable, keyboard-operable gutters - side by side or stacked, nested, collapsible and remembered
Overview
E.splitPane() turns the children of a container into panes and puts a gutter between each pair. Drag a gutter, or focus it and use the arrow keys, to share the space differently. It is what editors, mail clients and dashboards with a resizable sidebar are made of.
It is plain flexbox: each pane gets a flex-basis worked out from its share, nothing is absolutely positioned, and the container keeps its own size from your CSS. Sizes are percentages of the space the panes share, so the layout scales when the window does.
Key Features
- Two or more panes, side by side (
horizontal) or stacked (vertical) - Limits -
minSize/maxSizein px or %, per pane, withsnapOffset - Collapsible - double-click, a gutter button, Enter, or
collapse(i)/expand(i) - Remembered -
persistKeystores the sizes withSand restores them - Nests, works right to left, and keeps iframes from swallowing a drag
- Accessible - gutters are focusable
role="separator"elements with live values
Basic Usage
<div id="editor" style="height: 20rem">
<div>Files</div>
<div>Editor</div>
</div>
const split = E.splitPane('#editor', {sizes: [30, 70], minSize: 120});
split.on('dragend', (sizes) => console.log(sizes)); // [34.5, 65.5]
Two panes
Drag the gutter, or Tab to it and press the arrow keys (Shift for bigger steps, Home and End for the limits). Neither pane goes below 120px.
Sizes: 30 / 70
The panes scroll their own content when it does not fit.
<div id="demo-basic" class="sx-split mb-4">
<div class="sx-split-pane"><strong>Left</strong><p class="mt-2 mb-0">Sizes: <span id="demo-basic-out" class="sx-split-output">30 / 70</span></p></div>
<div class="sx-split-pane sx-split-alt"><strong>Right</strong><p class="mt-2 mb-0">The panes scroll their own content when it does not fit.</p></div>
</div>/** Two panes - 30 / 70 to start, neither narrower than 120px. */
export function mount(root) {
const shown = $(root).find('#demo-basic-out');
const basic = E.splitPane($(root).find('#demo-basic'), {sizes: [30, 70], minSize: 120});
basic.on('resize', (sizes) => shown.text(sizes.map(Math.round).join(' / ')));
return () => basic.destroy();
}const basic = E.splitPane('#demo-basic', {sizes: [30, 70], minSize: 120});
basic.on('resize', (sizes) => $('#demo-basic-out').text(sizes.map(Math.round).join(' / ')));
Nested, collapsible and remembered
Three panes across, the middle one split again top to bottom. The file list collapses (double-click its gutter, use the small button, or focus the gutter and press Enter). The preview is an <iframe>: dragging over it still works, because the panes stop taking pointer events while a drag is on. The sizes are stored under persistKey - reload the page and they come back.
- index.html
- site.css
- app.js
const split = E.splitPane('#ide', {
sizes: [20, 50, 30],
collapsible: [true, false, false]
});$ npm run build built in 1.4s
<div id="demo-ide" class="sx-split sx-split-tall mb-4">
<div class="sx-split-pane sx-split-alt">
<strong>Files</strong>
<ul class="mt-2 mb-0">
<li>index.html</li>
<li>site.css</li>
<li>app.js</li>
</ul>
</div>
<div id="demo-ide-middle">
<div class="sx-split-pane"><pre class="sx-split-code">const split = E.splitPane('#ide', {
sizes: [20, 50, 30],
collapsible: [true, false, false]
});</pre></div>
<div class="sx-split-pane sx-split-alt"><pre class="sx-split-code">$ npm run build
built in 1.4s</pre></div>
</div>
<div class="sx-split-pane sx-split-nopad">
<iframe class="sx-split-frame" title="Preview" srcdoc="<p style='font-family: sans-serif; padding: 1rem'>A preview in an iframe.</p>"></iframe>
</div>
</div>
<div class="flex gap-3 flex-wrap mb-4">
<button id="demo-ide-toggle" class="btn btn-sm btn-outline" type="button">Collapse files</button>
<button id="demo-ide-reset" class="btn btn-sm btn-outline" type="button">Reset sizes</button>
</div>/** Nested, collapsible and remembered - files, editor over terminal, and a preview. */
export function mount(root) {
const toggle = $(root).find('#demo-ide-toggle');
const reset = $(root).find('#demo-ide-reset');
const ide = E.splitPane($(root).find('#demo-ide'), {
sizes: [20, 50, 30],
minSize: [140, 200, 160],
collapsible: [true, false, false],
collapseButton: true,
persistKey: 'showcase-ide'
});
// The middle pane is split again, top to bottom
const middle = E.splitPane($(root).find('#demo-ide-middle'), {
direction: 'vertical',
sizes: [65, 35],
minSize: 60,
persistKey: 'showcase-ide-middle'
});
// The button follows the pane, however it was collapsed
const label = () => toggle.text(ide.isCollapsed(0) ? 'Expand files' : 'Collapse files');
label();
ide.on('collapse', label);
ide.on('expand', label);
const flip = () => (ide.isCollapsed(0) ? ide.expand(0) : ide.collapse(0));
const resetSizes = () => {
ide.setSizes([20, 50, 30]);
middle.setSizes([65, 35]);
label();
};
toggle.on('click', flip);
reset.on('click', resetSizes);
return () => {
toggle.off('click', flip);
reset.off('click', resetSizes);
middle.destroy();
ide.destroy();
};
}const ide = E.splitPane('#demo-ide', {
sizes: [20, 50, 30],
minSize: [140, 200, 160],
collapsible: [true, false, false],
collapseButton: true,
persistKey: 'showcase-ide'
});
const middle = E.splitPane('#demo-ide-middle', {
direction: 'vertical',
sizes: [65, 35],
minSize: 60,
persistKey: 'showcase-ide-middle'
});
const label = () => $('#demo-ide-toggle').text(ide.isCollapsed(0) ? 'Expand files' : 'Collapse files');
ide.on('collapse', label);
ide.on('expand', label);
$('#demo-ide-toggle').on('click', () => ide.isCollapsed(0) ? ide.expand(0) : ide.collapse(0));
$('#demo-ide-reset').on('click', () => { ide.setSizes([20, 50, 30]); middle.setSizes([65, 35]); });
Stacked, with snapping
direction: 'vertical' stacks the panes. With snapOffset: 40 a pane dragged within 40px of its minimum snaps to it, so a console can be put "away" without pixel hunting.
<div id="demo-vertical" class="sx-split mb-4">
<div class="sx-split-pane">Results</div>
<div class="sx-split-pane sx-split-alt">Console</div>
</div>/** Stacked, with snapping - drag the console near 40px and it snaps there. */
export function mount(root) {
const split = E.splitPane($(root).find('#demo-vertical'), {
direction: 'vertical',
sizes: [70, 30],
minSize: 40,
snapOffset: 40
});
return () => split.destroy();
}E.splitPane('#demo-vertical', {direction: 'vertical', sizes: [70, 30], minSize: 40, snapOffset: 40});
Right to left
In a dir="rtl" container the first pane is on the right; dragging and the arrow keys follow what you see.
<div id="demo-rtl" class="sx-split mb-4" dir="rtl">
<div class="sx-split-pane">الأول - first</div>
<div class="sx-split-pane sx-split-alt">الثاني - second</div>
</div>/** Right to left - in a dir="rtl" container the first pane is on the right. */
export function mount(root) {
const split = E.splitPane($(root).find('#demo-rtl'), {sizes: [40, 60]});
return () => split.destroy();
}E.splitPane('#demo-rtl', {sizes: [40, 60]});
Events
Every event is an onX option, an .on('x') listener and a bubbling dm:split-pane:x DOM event (detail = {instance, args}). Return false from onBeforeDrag to refuse a drag. This pane logs its drags through .on() and its collapsing through the DOM events.
Double-click the gutter to collapse me.
- Nothing yet - drag the gutter, or double-click it
<div id="demo-events" class="sx-split mb-4">
<div class="sx-split-pane sx-split-alt"><strong>Sidebar</strong><p class="mt-2 mb-0">Double-click the gutter to collapse me.</p></div>
<div class="sx-split-pane"><strong>Content</strong></div>
</div>
<ul id="demo-log" class="list-none p-0 text-sm text-gray-600 mb-4">
<li class="sx-split-empty">Nothing yet - drag the gutter, or double-click it</li>
</ul>/** Events - drags from .on() listeners, collapse and expand from the bubbling DOM events. */
export function mount(root) {
const list = $(root).find('#demo-log');
const log = (message) => {
list.find('.sx-split-empty').remove();
list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
list.find('li').filter((i) => i >= 8).remove();
};
const split = E.splitPane($(root).find('#demo-events'), {
sizes: [30, 70],
minSize: 120,
collapsible: [true, false]
});
split.on('dragstart', (sizes, gutter) => log('dragstart: gutter ' + gutter));
split.on('dragend', (sizes) => log('dragend: ' + sizes.map(Math.round).join(' / ')));
// Every event also bubbles as dm:split-pane:<event>, with {instance, args} in detail
const onToggle = (e) => log(e.type.replace('dm:split-pane:', '') + ': pane ' + e.detail.args[0]);
$(root).on('dm:split-pane:collapse dm:split-pane:expand', onToggle);
return () => {
$(root).off('dm:split-pane:collapse dm:split-pane:expand', onToggle);
split.destroy();
};
}const split = E.splitPane('#demo-events', {sizes: [30, 70], minSize: 120, collapsible: [true, false]});
split.on('dragend', (sizes) => log('dragend: ' + sizes.map(Math.round).join(' / ')));
// detail = {instance, args}
$('#demo-events').on('dm:split-pane:collapse', (e) => log('collapse: pane ' + e.detail.args[0]));
| Event | Arguments | When |
|---|---|---|
beforedrag |
(gutterIndex, event) |
Pointer down on a gutter; cancelable |
dragstart |
(sizes, gutterIndex) |
A drag begins |
drag |
(sizes, gutterIndex) |
Each move that changes the sizes |
dragend |
(sizes, gutterIndex) |
The pointer is released |
resize |
(sizes) |
Any change of size - drag, keys, setSizes, collapse |
collapse / expand |
(paneIndex) |
A pane collapses or opens again |
Keyboard and screen readers
Each gutter is a focusable role="separator" with aria-orientation, aria-valuenow (the size of the pane before it, in %), aria-valuemin / aria-valuemax and aria-controls pointing at that pane.
| Key | Does |
|---|---|
| ← → (side by side) / ↑ ↓ (stacked) | Move the gutter by step (1%) |
| Shift + arrow | Move by largeStep (10%) |
| Home / End | The pane before the gutter to its minimum / maximum |
| Enter | Collapse or expand, when a pane next to the gutter is collapsible |
Options
| Option | Type | Default | Description |
|---|---|---|---|
direction |
string | 'horizontal' |
'horizontal' (side by side) or 'vertical' (stacked) |
sizes |
number[] | equal | Percentages, one per pane; scaled to add up to 100 |
minSize / maxSize |
number | string | array | 0 / null |
px (120) or percent ('20%'); an array sets each pane |
gutterSize |
number | 8 |
Gutter thickness in px |
snapOffset |
number | 0 |
Snap to a limit when dragged within this many px of it |
collapsible |
boolean | boolean[] | false |
Which panes the gutter gestures collapse |
collapsedSize |
number | string | 0 |
What a collapsed pane shrinks to |
collapseButton |
boolean | false |
A small toggle button in gutters next to a collapsible pane |
persistKey |
string | null |
Store sizes and collapse state with S under split-pane:<key> |
cursor |
string | col-resize / row-resize |
Gutter cursor |
disabled |
boolean | false |
No dragging or keys; gutters leave the tab order |
step / largeStep |
number | 1 / 10 |
Percent per arrow key / Shift+arrow |
gutterLabel |
string | 'Resize panes' |
The gutters' aria-label |
Methods
| Method | Description |
|---|---|
getSizes() |
Current sizes, percentages that add up to 100 |
setSizes(sizes) |
Set every pane's size (clamped to the limits; expands collapsed panes) |
collapse(i) / expand(i) |
Collapse a pane toward its edge / give it its size back |
isCollapsed(i) |
Whether a pane is collapsed |
setOptions(opts) |
Change disabled, limits, gutter size, cursor, collapsing |
destroy() |
Remove the gutters and listeners and give the panes their own styles back |
panes |
The pane elements |
Theming
Gutters use --dm-border, turning --dm-primary on hover and while dragging, with a focus outline in --dm-primary. The container carries .dm-split-horizontal / .dm-split-vertical and .dm-split-dragging during a drag; collapsed panes carry .dm-split-collapsed.
#editor > .dm-split-gutter { background: transparent; }
#editor > .dm-split-gutter:hover { background: var(--dm-hover-bg); }