Skip to content

Instantly share code, notes, and snippets.

@greggman
Last active January 10, 2026 18:18
Show Gist options
  • Select an option

  • Save greggman/1d942226fa2fe2cbfcf01d5b0e3f7d5f to your computer and use it in GitHub Desktop.

Select an option

Save greggman/1d942226fa2fe2cbfcf01d5b0e3f7d5f to your computer and use it in GitHub Desktop.
HTML: Splitter
:root {
color-scheme: light dark;
--splitter-color: rgba(0,0,0,0.1);
--splitter-hover-color: rgba(0,0,0,0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--splitter-color: rgba(255,255,255,0.10);
--splitter-hover-color: rgba(255,255,255,0.16);
}
}
html, body {
height: 100%;
}
.split {
--gutter: 6px;
display: grid;
width: 100%;
height: 100%;
overflow: hidden;
/* JS fills --sizes */
grid-template-columns: var(--sizes);
grid-template-rows: 100%;
}
.split[data-axis="y"] {
grid-template-rows: var(--sizes);
grid-template-columns: 100%;
}
.pane {
min-width: 0;
min-height: 0;
overflow: auto;
}
.gutter {
background: var(--splitter-color);
touch-action: none;
}
.split[data-axis="x"] .gutter { cursor: col-resize; }
.split[data-axis="y"] .gutter { cursor: row-resize; }
.gutter:hover { background-color: var(--splitter-hover-color); }
<div class="split" data-axis="x" data-sizes="240px 1fr 2fr" style="height: 100%;">
<section class="pane">A</section>
<div class="gutter" role="separator" tabindex="0" aria-orientation="vertical"></div>
<section class="pane">B</section>
<div class="gutter" role="separator" tabindex="0" aria-orientation="vertical"></div>
<section class="pane">C</section>
</div>
function setupSplit(root, opts) {
const options = opts ?? {};
const axis = root.dataset.axis ?? 'x';
const isX = axis === 'x';
const panes = [];
const gutters = [];
for (const child of root.children) {
if (child.classList.contains('pane')) panes.push(child);
if (child.classList.contains('gutter')) gutters.push(child);
}
const paneMinPxDefault = options.paneMinPx ?? 64;
const paneMinPx = new Array(panes.length).fill(paneMinPxDefault);
// Pane weights (fr)
const weights = new Array(panes.length).fill(1);
function clamp(value, min, max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
function gutterPx() {
const v = getComputedStyle(root).getPropertyValue('--gutter').trim();
if (!v) return 6;
if (v.endsWith('px')) return Number.parseFloat(v);
return Number.parseFloat(v);
}
function containerSizePx() {
const rect = root.getBoundingClientRect();
return isX ? rect.width : rect.height;
}
function availablePx() {
const total = containerSizePx();
const g = gutterPx();
const guttersTotal = g * gutters.length;
return Math.max(0, total - guttersTotal);
}
function renderTracks() {
const g = gutterPx();
const parts = [];
for (let i = 0; i < panes.length; i++) {
parts.push(`minmax(0, ${weights[i]}fr)`);
if (i < gutters.length) parts.push(`${g}px`);
}
root.style.setProperty('--sizes', parts.join(' '));
}
function weightsToPx() {
const avail = availablePx();
const sum = weights.reduce((a, b) => a + b, 0);
const out = [];
for (let i = 0; i < weights.length; i++) {
out.push(sum > 0 ? (weights[i] / sum) * avail : 0);
}
return out;
}
function pxToWeights(pxSizes) {
const avail = availablePx();
const sum = weights.reduce((a, b) => a + b, 0);
const out = [];
for (let i = 0; i < pxSizes.length; i++) {
out.push(avail > 0 ? (pxSizes[i] / avail) * sum : 0);
}
return out;
}
function parseInitialSizes(spec) {
if (!spec) return;
const tokens = spec.trim().split(/\s+/);
if (tokens.length !== panes.length) return;
const avail = availablePx();
if (avail <= 0) return;
const pxSizes = new Array(panes.length).fill(0);
const fr = new Array(panes.length).fill(0);
let fixed = 0;
let frTotal = 0;
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.endsWith('px')) {
const v = Number.parseFloat(t);
if (Number.isFinite(v)) {
pxSizes[i] = Math.max(0, v);
fixed += pxSizes[i];
}
continue;
}
if (t.endsWith('%')) {
const v = Number.parseFloat(t);
if (Number.isFinite(v)) {
pxSizes[i] = Math.max(0, (v / 100) * avail);
fixed += pxSizes[i];
}
continue;
}
if (t.endsWith('fr')) {
const v = Number.parseFloat(t);
if (Number.isFinite(v)) {
fr[i] = Math.max(0, v);
frTotal += fr[i];
}
continue;
}
const v = Number.parseFloat(t);
if (Number.isFinite(v)) {
fr[i] = Math.max(0, v);
frTotal += fr[i];
}
}
const remaining = Math.max(0, avail - fixed);
if (frTotal === 0) {
const even = remaining / panes.length;
for (let i = 0; i < panes.length; i++) pxSizes[i] += even;
} else {
for (let i = 0; i < panes.length; i++) {
pxSizes[i] += remaining * (fr[i] / frTotal);
}
}
for (let i = 0; i < panes.length; i++) {
pxSizes[i] = Math.max(pxSizes[i], paneMinPx[i]);
}
const newWeights = pxToWeights(pxSizes);
for (let i = 0; i < weights.length; i++) weights[i] = newWeights[i];
}
// VS Code policy: only adjacent panes change.
function dragGutter(gutterIndex, clientPos) {
const rect = root.getBoundingClientRect();
const start = isX ? rect.left : rect.top;
const g = gutterPx();
const pxSizes = weightsToPx();
const leftIndex = gutterIndex;
const rightIndex = gutterIndex + 1;
const span = pxSizes[leftIndex] + pxSizes[rightIndex];
const p = clamp(clientPos - start, 0, containerSizePx());
let before = 0;
for (let i = 0; i < leftIndex; i++) {
before += pxSizes[i];
before += g;
}
const rawLeft = p - before;
const leftMin = paneMinPx[leftIndex];
const rightMin = paneMinPx[rightIndex];
const newLeft = clamp(rawLeft, leftMin, span - rightMin);
const newRight = span - newLeft;
pxSizes[leftIndex] = newLeft;
pxSizes[rightIndex] = newRight;
const newWeights = pxToWeights(pxSizes);
for (let i = 0; i < weights.length; i++) weights[i] = newWeights[i];
renderTracks();
}
function attachGutters() {
for (let i = 0; i < gutters.length; i++) {
const gutter = gutters[i];
gutter.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const pointerId = e.pointerId;
let active = true;
function getPos(ev) {
return isX ? ev.clientX : ev.clientY;
}
function cleanup() {
if (!active) return;
active = false;
gutter.removeEventListener('pointermove', onMove);
gutter.removeEventListener('pointerup', onUp);
gutter.removeEventListener('pointercancel', onCancel);
gutter.removeEventListener('lostpointercapture', onLostCapture);
if (gutter.hasPointerCapture(pointerId)) {
gutter.releasePointerCapture(pointerId);
}
}
function onMove(ev) {
if (!active) return;
if (ev.pointerId !== pointerId) return;
dragGutter(i, getPos(ev));
}
function onUp(ev) {
if (ev.pointerId !== pointerId) return;
cleanup();
}
function onCancel(ev) {
if (ev.pointerId !== pointerId) return;
cleanup();
}
function onLostCapture(ev) {
if (ev.pointerId !== pointerId) return;
cleanup();
}
gutter.setPointerCapture(pointerId);
gutter.addEventListener('pointermove', onMove);
gutter.addEventListener('pointerup', onUp);
gutter.addEventListener('pointercancel', onCancel);
gutter.addEventListener('lostpointercapture', onLostCapture);
dragGutter(i, getPos(e));
}, { passive: true });
}
}
function init() {
renderTracks();
parseInitialSizes(root.dataset.sizes);
renderTracks();
}
attachGutters();
// If the element is visible now, this is enough. If it might be display:none at startup,
// call the returned .refresh() after it becomes visible.
requestAnimationFrame(() => init());
return {
refresh() {
init();
},
setSizes(spec) {
parseInitialSizes(spec);
renderTracks();
},
setPaneMinPx(index, px) {
paneMinPx[index] = px;
renderTracks();
},
};
}
// usage
for (const root of document.querySelectorAll('.split')) {
setupSplit(root);
}
{"name":"HTML: Splitter","settings":{},"filenames":["index.html","index.css","index.js"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment