Last active
January 10, 2026 18:18
-
-
Save greggman/1d942226fa2fe2cbfcf01d5b0e3f7d5f to your computer and use it in GitHub Desktop.
HTML: Splitter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :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); } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"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