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 / maxSize in px or %, per pane, with snapOffset
  • Collapsible - double-click, a gutter button, Enter, or collapse(i) / expand(i)
  • Remembered - persistKey stores the sizes with S and 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.

Two panes
Left

Sizes: 30 / 70

Right

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.

Nested, collapsible and remembered
Files
  • 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.

Stacked, with snapping
Results
Console
<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.

Right to left
الأول - first
الثاني - second
<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.

Events
Sidebar

Double-click the gutter to collapse me.

Content
  • 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); }