GODRICH

9 Resizable Split Pane Layouts With No Library

A resizable split pane is a layout whose panes share one draggable boundary, so widening one side narrows the other.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by what the divider learns to do, not by popularity. The first three are the fundamentals: a boundary that slides left and right (01), the same idea rotated onto the vertical axis (02), and both of them joined into a sidebar, an editor, and a console on one screen (03). From the fourth demo on, the divider starts to remember things. It folds and unfolds on a double-click (04), sticks to tidy ratios like a magnet (05), changes shape when the pointer comes near (06), and brings back the last ratio after the tab is closed and reopened (07). The eighth turns the boundary into a single corner point (08), and the last one (09) changes the direction of the boundary itself according to how wide its container is. All nine run on one set of pointerdown, pointermove, and pointerup handlers plus a single CSS custom property, with no library at all, and every one of the nine handles takes keyboard input as well.

01Vertical splitter

Drag the middle handle and the width of the left pane tracks the pointer exactly. That width lives in a single custom property, --split, which grid-template-columns reads, and the value stops dead once either side reaches its 64px minimum.

setPointerCapturegrid-template-columnsaria-valuenow
function set(pct) {
  var w = box.clientWidth;
  var lo = MIN / w * 100, hi = (w - MIN - BAR) / w * 100;
  var v = Math.max(lo, Math.min(pct, hi));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100);
});

02Horizontal splitter

Dragging the handle up or down trades height between the editor pane above and the result pane below. It is the same arithmetic rotated onto the Y axis, and the up and down arrow keys shift the same value five percent at a time.

pointermoveclamparia-orientation
function clamp(v, lo, hi) { return Math.max(lo, Math.min(v, hi)); }
function set(pct) {
  var h = box.clientHeight;
  var v = clamp(pct, MIN / h * 100, (h - MIN - BAR) / h * 100);
  root.style.setProperty('--splitY', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('keydown', function (e) {
  var d = e.key === 'ArrowUp' ? -5 : e.key === 'ArrowDown' ? 5 : 0;
  if (!d) return;
  stop();
  set(Number(bar.getAttribute('aria-valuenow')) + d);
  bar.focus({ preventScroll: true });
  e.preventDefault();
});

03Three-pane editor layout

A file list, an editor, and a console are joined by two handles, and moving one takes room only from the pane beside it. The outer grid splits the columns while the inner grid splits the rows, so neither handle can touch the other's value.

grid-template-columnsgrid-template-rowssetPointerCapture
.tp__box {
  display: grid;
  grid-template-columns: var(--col) 8px 1fr;
  height: 128px;
  overflow: hidden;
}
.tp__main {
  display: grid;
  grid-template-rows: var(--row) 8px 1fr;
  overflow: hidden;
}

04Double-click collapse

Double-click the handle and the pane beside it folds into a thin rail; double-click again and it returns to the width it had just before folding. The pre-fold value is kept in one variable, so unfolding restores the last width the reader chose rather than a default.

dblclickaria-expandedtransition
bar.addEventListener('dblclick', function () {
  stop();
  folded = !folded;
  if (folded) set(RAIL, true);
  else set(last, false);
});
function set(pct, fold) {
  root.style.setProperty('--split', pct.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(pct)));
  bar.setAttribute('aria-expanded', String(!fold));
  root.classList.toggle('is-folded', fold);
}

05Snapping guide lines

Guide lines sit at 25, 50, and 75 percent while you drag, and the value locks onto one as soon as the handle comes within six percent of it. Since only distance matters, the whole decision fits into one Math.abs comparison.

snapopacityMath.abs
var STOPS = [25, 50, 75], NEAR = 6;
function snap(pct) {
  var best = pct;
  for (var i = 0; i < STOPS.length; i++) {
    if (Math.abs(pct - STOPS[i]) < NEAR) best = STOPS[i];
  }
  return Math.max(10, Math.min(best, 90));
}
function set(pct) {
  var v = snap(pct);
  root.style.setProperty('--split', v + '%');
  root.classList.toggle('is-snapped', STOPS.indexOf(v) >= 0);
}

06Handle grip feedback

At rest the boundary is a hairline; on hover it widens and shows three grip dots, and for as long as the drag lasts it stays in the highlighted color. The widening is a scaleX transform rather than a real width change, so the neighboring panes never shift while it happens.

scaleXcubic-beziercursor
.hg__hair {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 20px;
  border-radius: $r-pill;
  background: $subject-ink;
  transform: translateX(-10px) scaleX(.1);
  transition: transform $duration $easing, background $duration $easing;
}
.hg__bar:hover .hg__hair { transform: translateX(-10px) scaleX(1); }
.hg.is-drag .hg__hair { background: $subject-blue; }

07Saved ratio presets

The ratio you drag to is written to localStorage right away, and the next visit starts from that stored value. The 1:2, 1:1, and 2:1 preset buttons set the same number in one click and report the current choice through aria-pressed.

localStorageJSON.parsearia-pressed
function set(pct, mark) {
  var v = Math.max(20, Math.min(pct, 80));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
  btns.forEach(function (b) {
    var on = mark && b.dataset.v === String(Math.round(v));
    b.classList.toggle('is-on', !!on);
    b.setAttribute('aria-pressed', String(!!on));
  });
  try { localStorage.setItem(KEY, JSON.stringify({ split: Math.round(v) })); } catch (err) { return; }
}

08Card corner grip

Grab the bottom-right corner and the width and the height grow together; with the lock button on, the height always follows the width times the original ratio. The size printed inside the card is read from the same pair of custom properties, so the label can never disagree with the box it sits in.

pointerdownaspectMath.round
var RATIO = 96 / 176, locked = true;
function set(w, h) {
  var W = Math.max(120, Math.min(Math.round(w), 220));
  var H = locked ? Math.round(W * RATIO) : Math.max(64, Math.min(Math.round(h), 132));
  root.style.setProperty('--w', W + 'px');
  root.style.setProperty('--h', H + 'px');
  root.style.setProperty('--wn', String(W));
  root.style.setProperty('--hn', String(H));
  grip.setAttribute('aria-valuenow', String(W));
}

09Responsive auto stack

The moment the container drops below 200px, the two side-by-side panes stack instead, and the handle turns from vertical to horizontal along with them. The demo measures its own box with ResizeObserver instead of asking a media query about the window, so the same component flips correctly even inside a narrow sidebar.

ResizeObserveraria-orientationgrid-template-rows
var ro = new ResizeObserver(function (entries) {
  var w = entries[0].contentRect.width;
  var next = w < FLIP;
  if (next === stacked) return;
  stacked = next;
  root.classList.toggle('is-stack', stacked);
  bar.setAttribute('aria-orientation', stacked ? 'horizontal' : 'vertical');
});
ro.observe(box);

Where it breaks — the trap

The trap all nine share is that a custom property holding the ratio produces no in-between values unless it has been registered. A name like --split is just a string by default, so the browser has no idea what sits between 50% and 26%. Deleting only the @property --split { syntax: "<percentage>" } block from the first demo and re-measuring the same two-second loop dropped the frames with visible movement from 19 out of 24 to 7; the average difference across the changed pixels fell from 121.9 to 64.6. In other words, the width stopped flowing and began jumping from one keyframe stop to the next. Putting the declaration back brought the same code straight back to 19 frames. Dragging with a mouse hides this completely, because the script writes a fresh value on every frame; the problem shows up only where CSS has to fill the gap itself, such as the fold animation or a preset move.

The second trap was the number and the picture drifting apart. Demo 08 prints its own size as text, and as long as that text was updated from script alone, the card kept growing during autoplay while the label stayed frozen at its starting value. Demo 07 was subtler: its preset buttons lit up at the moment of the click, so a frame captured mid-glide showed 2:1 pressed while the panes were still at 1:1. Demo 08 now derives the text from an integer property read by counter(), tied to the very values that size the card, and demo 07 waits until the ratio has arrived before marking a button. Neither fault trips an automated check; both show up only as an odd frame now and then.

Symptom Actual cause Where to fix it
Folding and preset moves jump the ratio property is unregistered, so there are no in-between values @property with syntax: "<percentage>"
The drag stops when the pointer slips off the pointer is not bound to the handle setPointerCapture inside pointerdown
The layout fails to flip on narrow screens the window is being measured ResizeObserver on the box itself

All three rows are already fixed in the nine sources inside the zip, which opens with the archive password 34ze99gp, and the vanilla and React builds share the same values.

Accessibility (reduced-motion)

Not one of the nine is mouse-only. Every boundary carries role="separator" and aria-orientation so that its purpose and its direction are announced, with aria-valuemin, aria-valuemax, and aria-valuenow reading out the current ratio as a number. Demos 01, 02, 03, 05, 06, 07, and 09 move on the left and right or the up and down arrows, demo 04 folds and unfolds on Enter and Space, and demo 08 shifts the card width eight pixels at a time on the horizontal arrows. Demo 07 also reaches its ratio without the handle at all, because its three presets are real buttons. The handle in demo 04 also carries aria-expanded, and demo 09 updates aria-orientation when the layout flips. Keyboard moves call focus({ preventScroll: true }) because these demos sit in iframes inside listing pages, and without that option the parent page jumps downward on every keypress. Under prefers-reduced-motion: reduce only the autoplay loop and the transitions stop, while dragging, keyboard control, and the values themselves stay. The role behind a draggable boundary is documented in the MDN separator role reference.

For parts that pair well with these, the dashboard category and the click category collect the closest neighbors.

FAQ

Why put the ratio in a grid template rather than a width?

Because the arithmetic changes the moment there are three tracks. Giving each pane its own width means subtracting the handle thickness by hand and deciding in code which pane absorbs the change. Writing grid-template-columns: var(--split) 8px 1fr moves the handle into a fixed track and lets 1fr take whatever is left, so the only moving number is --split. Demo 03 applies the same pattern once to columns and once to rows, which is what keeps its two handles independent.

Why does the drag stop when the cursor leaves the handle?

Because the pointer was never bound to it. By default, an element stops receiving events as soon as the pointer crosses its boundary, so a fast drag loses the handle halfway. Calling setPointerCapture(e.pointerId) once inside pointerdown keeps events flowing to that handle until the button is released, even outside the panel or past the window edge. All nine demos have a drag, and every one of them rests on that single line.

Why does my saved ratio not come back in some browsers?

In private windows, and under other locked-down storage settings, writing to localStorage throws. That is why demo 07 wraps both the save and the restore in try. A thrown error simply means the value could not be read, so the layout starts at its default ratio; leave the guard out and the script dies at that line in those browsers, which stops the handle from moving at all.

Enter the archive password

The password is inside this article. You will find it as you read.