ESC

Type to search the knowledge base.

Resizable Split Panes

Drag a splitter to resize two panes, with min widths, keyboard nudges, and pointer capture so the drag does not stall on iframes.

intermediate6 min read
  • machine-coding
  • interview
  • pointer-events
  • a11y

Mouse-move on window looks fine until the pointer crosses an iframe, a preview, or the other pane’s iframe docs. Events stop. The drag sticks. Resizable split panes fix that with setPointerCapture on the separator: all later pointer events route to that element until release, even over iframes.

The second scoring item is the model. Store a percent (or a fraction 0–1), not leftover pixels from the first layout. Apply min widths as clamps in that fraction. The separator is a widget: role="separator", aria-valuenow, arrow keys.

Key takeaways

  • State is leftRatio (0–1) or percent, not two independent pixel widths that drift.
  • element.setPointerCapture(pointerId) on pointer down. Release on up/cancel.
  • Clamp so each pane keeps minWidth (and a min for the splitter itself).
  • Keyboard: arrows nudge, Home/End to min/max.
  • role="separator" plus aria-valuenow / min / max / orientation.

Pointer capture on the separator, percent width in state

Layout:

[ pane A | splitter | pane B ]

Flex: left pane flex: 0 0 ${leftRatio * 100}%, splitter fixed pixels, right pane flex: 1. The right pane eats remainder, so rounding does not leave a gap. CSS Grid with grid-template-columns: ${left}px 8px 1fr is also fine if you convert the ratio to pixels from the container’s current width while dragging, then persist the ratio.

function clampRatio(
  raw: number,
  containerWidth: number,
  minPx: number,
  splitterPx: number
) {
  const min = minPx / containerWidth;
  const max = 1 - (minPx + splitterPx) / containerWidth;
  return Math.min(max, Math.max(min, raw));
}

Pointer capture:

function onPointerDown(e: React.PointerEvent<HTMLButtonElement>) {
  e.currentTarget.setPointerCapture(e.pointerId);
}

function onPointerMove(
  e: React.PointerEvent<HTMLButtonElement>,
  container: HTMLElement,
  minPx: number,
  splitterPx: number,
  setRatio: (n: number) => void
) {
  if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
  const rect = container.getBoundingClientRect();
  const raw = (e.clientX - rect.left) / rect.width;
  setRatio(clampRatio(raw, rect.width, minPx, splitterPx));
}

function onPointerUp(e: React.PointerEvent<HTMLButtonElement>) {
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
    e.currentTarget.releasePointerCapture(e.pointerId);
  }
}

Without capture, you subscribe to pointermove on window and still lose events over cross-origin iframes. Capture is the API that exists for this. e.preventDefault() on pointerdown also avoids accidental text selection while dragging.

Vertical split (top/bottom) is the same code with clientY and height. aria-orientation="horizontal" for a splitter that moves left-right (it separates two regions along the horizontal axis: check APG if they quibble). Many implementations use vertical for a tall bar. Match APG window splitter: orientation is the direction of the separator bar. Say which you picked.

Problem statement

Build two panes with a draggable splitter. Drag resizes them. Each pane has a minimum size. Keyboard nudges the splitter. Pointer capture keeps the drag alive across iframes and out-of-widget movement.

Requirements

Must have

  • Two panes + splitter
  • Drag to resize
  • Min size on both panes
  • Keyboard: arrows (and Home/End)
  • role="separator" and current value exposed
  • Pointer capture on the splitter

Should have

  • Percent persisted (controlled value + onChange)
  • Double-click splitter to reset 50/50
  • touch-action: none on the handle so the page does not scroll

Nice to have

  • Collapse one pane
  • Nested splits
  • Vertical orientation prop

Planning (5 minutes out loud)

  1. Ratio in state, pixels only at event time
  2. Pointer capture, not mousemove on document as the whole story
  3. MVP: drag + clamp
  4. Then keyboard + ARIA
  5. Iframe as the test: drop a map or preview in pane B and drag over it

Architecture

SplitPanes
├── Pane (left, overflow auto)
├── Separator (button)
└── Pane (right)

API

type SplitPanesProps = {
  value?: number; // 0–1, controlled
  defaultValue?: number;
  onChange?: (ratio: number) => void;
  minPx?: number;
  splitterPx?: number;
  left: React.ReactNode;
  right: React.ReactNode;
};

Implementation sketch

function SplitPanes({
  defaultValue = 0.5,
  minPx = 80,
  splitterPx = 8,
  left,
  right,
  onChange,
}: SplitPanesProps) {
  const [ratio, setRatio] = useState(defaultValue);
  const boxRef = useRef<HTMLDivElement>(null);

  function commit(next: number) {
    setRatio(next);
    onChange?.(next);
  }

  function ratioFromClientX(clientX: number) {
    const box = boxRef.current;
    if (!box) return ratio;
    const rect = box.getBoundingClientRect();
    const raw = (clientX - rect.left) / rect.width;
    return clampRatio(raw, rect.width, minPx, splitterPx);
  }

  function nudge(delta: number) {
    const box = boxRef.current;
    if (!box) return;
    const next = clampRatio(
      ratio + delta,
      box.getBoundingClientRect().width,
      minPx,
      splitterPx
    );
    commit(next);
  }

  const percent = Math.round(ratio * 100);

  return (
    <div ref={boxRef} className="split">
      <div className="pane" style={{ flexBasis: `${percent}%` }}>
        {left}
      </div>
      <button
        type="button"
        role="separator"
        aria-orientation="vertical"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={percent}
        aria-label="Resize panes"
        style={{ width: splitterPx, touchAction: "none" }}
        onPointerDown={(e) => {
          e.preventDefault();
          e.currentTarget.setPointerCapture(e.pointerId);
        }}
        onPointerMove={(e) => {
          if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
          commit(ratioFromClientX(e.clientX));
        }}
        onPointerUp={(e) => {
          if (e.currentTarget.hasPointerCapture(e.pointerId)) {
            e.currentTarget.releasePointerCapture(e.pointerId);
          }
        }}
        onPointerCancel={(e) => {
          if (e.currentTarget.hasPointerCapture(e.pointerId)) {
            e.currentTarget.releasePointerCapture(e.pointerId);
          }
        }}
        onDoubleClick={() => commit(0.5)}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") {
            e.preventDefault();
            nudge(-0.02);
          }
          if (e.key === "ArrowRight") {
            e.preventDefault();
            nudge(0.02);
          }
          if (e.key === "Home") {
            e.preventDefault();
            nudge(-1);
          }
          if (e.key === "End") {
            e.preventDefault();
            nudge(1);
          }
        }}
      />
      <div className="pane pane-fill">{right}</div>
    </div>
  );
}
.split {
  display: flex;
  align-items: stretch;
  min-height: 12rem;
}
.pane {
  min-width: 0;
  overflow: auto;
}
.pane-fill {
  flex: 1 1 auto;
}

min-width: 0 on flex children is the usual overflow fix. Your JS min is a drag clamp; CSS min-width on panes can fight the flex basis if you set both. Prefer JS clamp plus min-width: 0 so content scrolls instead of blocking the split.

Hit target: 8px visual bar, larger padding, or a ::before that widens the pointer area. 8px is tight for touch.

Accessibility essentials

Piece Approach
Splitter Focusable control (button) with role="separator"
Value aria-valuemin/max/now as percent
Keys Left/Right (or Up/Down if horizontal panes), Home/End
Label aria-label="Resize panes"
Panes Own landmarks or headings if they are regions

role="separator" with values makes it a window splitter, not a decorative hr. A non-focusable div handle fails the keyboard requirement even if drag works.

Interview scoring

Signal What they want
Capture setPointerCapture, they will ask why
Model Ratio, clamped to mins
Keyboard Nudge + Home/End
ARIA separator + valuemin/now/max
Iframe You mention lost events without capture
CSS Flex/grid that does not overflow leftover pixels

They may drop an iframe in the right pane and tell you to drag across it. If you only listened on mousemove of the parent, you fail that demo.

Footguns

  1. No pointer capture, drag dies on iframe / iframe-like embeds
  2. Pixel left width that is wrong after the window resizes
  3. Mins that sum past 100% with no clamp
  4. Unfocusable splitter
  5. overflow: hidden eating pane content instead of min-width: 0 + scroll
  6. Forgetting touch-action: none, the page steals the gesture

Interview out-loud answer

I would keep the split as a fraction of the container and clamp it so both panes keep a min pixel width. The handle is a button with role="separator" and aria-valuenow as a percent. On pointer down I setPointerCapture so moves still arrive over iframes. Pointer move converts clientX to a ratio from the container rect. Arrows nudge by a couple of percent, Home and End jump to the clamps. I persist the ratio, not a stale pixel width, so a window resize keeps the same proportion.

Further reading