DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a resizable split view from scratch: one number, pointer capture, and the ARIA separator pattern

Every serious tool has one: the draggable divider that splits a code editor from its preview, a mail list from a message, a file tree from a document. It looks physical — you grab a bar and shove it — but underneath it is one number and a scrap of arithmetic. I built a real split view in about 90 lines of vanilla JS, with keyboard support and free nesting, and here is the whole idea.

The layout does the heavy lifting

Two panes sit in a flex container with a gutter between them. The primary pane (the first child) is pinned to a flex-basis percentage that JavaScript rewrites; the second pane is flex:1 1 0, so it soaks up whatever is left after the primary and the gutter. That means resizing never touches the second pane — you only ever write one number.

.split           { display:flex; height:100%; }
.split.is-row    { flex-direction:row; }
.split.is-col    { flex-direction:column; }
.split > .pane   { flex:1 1 0; min-width:0; overflow:auto; } /* fills */
.split > .pane:first-child { flex:0 0 auto; }  /* JS writes flex-basis here */
.split > .gutter { flex:0 0 10px; touch-action:none; }
Enter fullscreen mode Exit fullscreen mode

min-width:0 matters — without it a long line or wide image refuses to shrink and the drag feels stuck.

Dragging is just arithmetic

Take the pointer coordinate, subtract the container's start edge, divide by its size: that is "how far along, 0–100%". Row splits use the X axis, column splits use Y. Using percent rather than pixels keeps the proportions when the window resizes — and it is exactly the number aria-valuenow wants.

function positionToPercent(split, clientX, clientY){
  const r = split.getBoundingClientRect();
  return split.classList.contains("is-row")
    ? (clientX - r.left) / r.width  * 100     // row: X axis
    : (clientY - r.top)  / r.height * 100;    // col: Y axis
}
Enter fullscreen mode Exit fullscreen mode

One gate every change passes through

A raw percent can go negative or past 100 when the pointer leaves the container. So every candidate value — from a drag, the keyboard, a reset — passes through one apply() that clamps it and does two writes together: the primary's flex-basis (what the eye sees) and aria-valuenow (what a screen reader hears). Funnelling everything through one function is what makes the visual state and the accessible state impossible to desynchronise.

function apply(p){
  pos = Math.min(max, Math.max(min, p));   // clamp to [min, max]
  primary.style.flexBasis = pos + "%";     // visual
  gutter.setAttribute("aria-valuenow", Math.round(pos));   // semantic
}
Enter fullscreen mode Exit fullscreen mode

Mouse and touch in one path: Pointer Events

Instead of separate mouse and touch handlers, I use Pointer Events — one stream covering mouse, touch and pen. The star is setPointerCapture on pointerdown: it routes every later move and the eventual up to the gutter even when the pointer wanders far off it, which is what happens in a fast drag. No document-level listeners, no "lost the drag" bug.

gutter.addEventListener("pointerdown", e => {
  gutter.setPointerCapture(e.pointerId);   // route all moves here till release
  e.preventDefault();
});
gutter.addEventListener("pointermove", e => {
  if (!gutter.hasPointerCapture(e.pointerId)) return;  // dragging only
  apply(positionToPercent(split, e.clientX, e.clientY));
});
Enter fullscreen mode Exit fullscreen mode

Pair it with touch-action:none on the gutter so the browser drags instead of scrolling.

Keyboard: the WAI-ARIA window-splitter contract

A mouse-only resizer is inaccessible. The separator gets tabindex="0" so Tab reaches it, then the arrow keys nudge it (Left/Up shrink the primary, Right/Down grow it), Shift takes bigger jumps, Home/End jump to min/max, and Enter resets. Every branch calls the same apply(), so keyboard resizing is clamped and mirrored into ARIA for free.

gutter.addEventListener("keydown", e => {
  const step = e.shiftKey ? 10 : 2;
  switch (e.key){
    case "ArrowLeft": case "ArrowUp":    apply(pos - step); break;
    case "ArrowRight": case "ArrowDown": apply(pos + step); break;
    case "Home": apply(min); break;
    case "End":  apply(max); break;
    case "Enter": apply(DEFAULT); break;
    default: return;                     // let other keys through
  }
  e.preventDefault();
});
Enter fullscreen mode Exit fullscreen mode

The ARIA gotcha that trips everyone

aria-orientation describes the separator line, not how the panes are arranged. Side-by-side panes have a vertical divider (aria-orientation="vertical"); stacked panes have a horizontal one. This is the opposite of most people's first guess, and getting it backwards makes screen readers announce the wrong resize direction.

Nesting is free

Wrap all of the above in one initSplit(el) that reads its min/max/default from data attributes and scopes every query with :scope > so an instance only ever touches its own pane and gutter. Now nesting comes for nothing: drop a second .split inside a pane, run initSplit on both, and the child resizes independently. In the demo, the right pane splits again with no special case at all.

Grab the divider, flip the orientation, or focus it and use the arrow keys:
https://dev48v.infy.uk/design/day36-split-panes.html

Top comments (0)