A dependency-free tiling split-layout engine for the web. Nested row/column panes with every-frame-perfect spring animation, drag-to-re-split (with magnetic drop zones), drag-to-resize dividers, focus management, and add-a-panel-from-any-edge — in ~13 KB of vanilla JS, no framework required.
▶ Watch the full-quality video
Inspired by native split-layout frameworks (the kind you'd ship in a terminal like Ghostty). This is a from-scratch web implementation with a CoreAnimation-style spring driving every reflow.
- 🧩 Tiling tree — arbitrary nesting of row/column splits
- 🌊 Every-frame-perfect — a critically-damped spring animates every pane's
{x, y, w, h}on each frame, so moves/closes/resizes glide - 🖱️ Drag to re-split — drag a pane's header onto another pane; a magnetic drop zone shows left/right/top/bottom/center targets
↔️ Drag to resize — grab the gaps between panes- ➕ Add from any edge — full-height/full-width panels on left/right/top/bottom
- 🎯 Focus, move, close, swap — full programmatic API + events
- 💾 Serialize / restore — persist and reload layouts as plain JSON
- 🎨 Themeable — CSS custom properties, 7 built-in pastel palettes + icons
- 📦 Zero dependencies — ESM, UMD (browser global) and CommonJS builds
npm install supersplit-js…or drop it in from a CDN:
<link rel="stylesheet" href="https://unpkg.com/supersplit-js/dist/supersplit.css">
<script src="https://unpkg.com/supersplit-js/dist/supersplit.umd.js"></script>
<!-- window.SuperSplit is now available --><link rel="stylesheet" href="node_modules/supersplit-js/dist/supersplit.css">
<div id="app" style="width: 100%; height: 100vh;"></div>
<script type="module">
import SuperSplit from 'supersplit-js';
const ss = new SuperSplit('#app', {
layout: {
type: 'split', dir: 'row', sizes: [2, 1], children: [
{ id: 'editor', title: 'Editor', color: 'blue', icon: 'terminal' },
{ type: 'split', dir: 'col', children: [
{ id: 'preview', title: 'Preview', color: 'green', icon: 'doc' },
{ id: 'console', title: 'Console', color: 'rose', icon: 'grid' },
]},
],
},
});
ss.on('change', (layout) => localStorage.setItem('layout', JSON.stringify(layout)));
</script>The container must have a size — SuperSplit fills it 100% × 100%.
Use renderPane to fill each pane's body, or set content per pane (an HTML
string or an Element):
const ss = new SuperSplit('#app', {
layout: { /* … */ },
renderPane(pane, body, instance) {
// `body` is the pane's content element
body.innerHTML = `<iframe src="/panes/${pane.id}"></iframe>`;
},
});container is an element or a selector. Options (all optional):
| option | default | description |
|---|---|---|
layout |
single pane | initial layout spec |
gap |
9 |
px gap between sibling panes |
padding |
13 |
px padding inside the surface |
minPaneSize |
64 |
smallest a pane can be dragged to (px) |
paneRadius |
11 |
pane corner radius (px) |
headerHeight |
37 |
pane header height (px) |
accent |
#2f7bff |
focus ring / drop-zone color |
spring |
{stiffness:240, damping:28, mass:1} |
animation feel |
draggable |
true |
drag headers to re-split / swap |
resizable |
true |
drag the gaps to resize |
closable |
true |
show per-pane close buttons |
header |
true |
render the pane header bar |
renderPane |
null |
(pane, bodyEl, instance) => void |
ss.addPane(side, props?) // 'left' | 'right' | 'top' | 'bottom' → id
ss.splitPane(targetId, side, props?)// split a specific pane → id | null
ss.closePane(id) // remove a pane (keeps at least one)
ss.focusPane(id)
ss.movePane(id, dir) // 'left' | 'right' | 'up' | 'down'
ss.swapPanes(idA, idB)
ss.getFocused() // → id | null
ss.getPanes() // → [{ id, title, color, icon, data }]
ss.getNeighbor(id, dir) // → id | null
ss.serialize() // → layout spec (JSON-friendly)
ss.load(layout) // replace the whole layout
ss.settle() // jump all springs to their target
ss.resize() // recompute (auto via ResizeObserver)
ss.destroy() // remove DOM, observers and listenersprops for new panes: { id?, title?, color?, icon?, content?, data? }.
ss.on('focus', (id) => {});
ss.on('change', (layout) => {}); // any structural or size change
ss.on('add', (pane) => {});
ss.on('close', (id) => {});
ss.on('split', ({ id, target, side }) => {});
ss.on('move', ({ id, dir }) => {});
ss.on('drop', ({ id, target, side }) => {});
ss.on('resize', () => {});
ss.on('dragstart', (id) => {});ss.off(type, fn?) removes a handler (or all handlers for a type).
A tree of split and pane nodes:
// split node
{ type: 'split', dir: 'row' | 'col', sizes?: number[], children: [ … ] }
// pane node (leaf)
{ type: 'pane', id?, title?, color?, icon?, content?, data? }sizes are relative weights (default equal). serialize() emits exactly this
shape, so ss.load(ss.serialize()) round-trips.
All visuals are driven by CSS custom properties on the injected .ss-surface.
Set them via options, or override in CSS:
.ss-surface {
--ss-accent: #ff6f3c;
--ss-gap: 6px;
--ss-pane-radius: 8px;
}Built-in pane palettes (pane.color): green, rose, blue, peach,
periwinkle, lavender, indigo, plus the neutral default.
Inspect or extend them via SuperSplit.palettes. Built-in pane.icon names live
in SuperSplit.icons (sliders, grid, terminal, doc, sidebar,
columns, playbox) — or pass any raw SVG string as the icon.
The examples/ folder contains the full demo from the screenshot — a macOS-style
window with a toolbar (move, add-from-edge, close, reset), keyboard shortcuts and
layout persistence. Run it locally:
git clone https://github.com/schappim/supersplit-js
cd supersplit-js
npm run build # generate dist/ (already committed, but just in case)
npm run serve # → http://localhost:8080 (or: node serve.js, PORT=3000 node serve.js)
serve.jsis a tiny zero-dependency static server that sends the correct MIME types. Many simple servers send.cssastext/plain(which modern browsers refuse) and mis-serve ES modules — if you hit that, useserve.js.
The library has no build dependencies — build.js is plain Node that emits the
ESM, UMD and CommonJS bundles from src/:
npm run buildModern evergreen browsers (Chrome, Safari, Firefox, Edge). Uses Pointer Events,
ResizeObserver, the Web Animations API and CSS container queries.
MIT © Marcus Schappi