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.
- 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"plusaria-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: noneon the handle so the page does not scroll
Nice to have
- Collapse one pane
- Nested splits
- Vertical orientation prop
Planning (5 minutes out loud)
- Ratio in state, pixels only at event time
- Pointer capture, not
mousemoveondocumentas the whole story - MVP: drag + clamp
- Then keyboard + ARIA
- 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
- No pointer capture, drag dies on iframe / iframe-like embeds
- Pixel left width that is wrong after the window resizes
- Mins that sum past 100% with no clamp
- Unfocusable splitter
overflow: hiddeneating pane content instead ofmin-width: 0+ scroll- 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"andaria-valuenowas a percent. On pointer down IsetPointerCaptureso moves still arrive over iframes. Pointer move convertsclientXto 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.
Related on this site
- Drag and Drop List Reorder
- Split Markdown Editor
- Keyboard Accessibility Checklist
- Flexbox fundamentals
- Capture, bubble, once, passive