A split pane lets someone give more room to one of two adjacent regions. Build the React version with a pointer-draggable, keyboard-operable separator whose left-pane percentage stays between 20 and 80.
Implement the App component in App.tsx. It receives no props and starts with the Editor and Preview panes at 50% / 50%.
80% / 20%.pointermove and pointerup live on window only for the active drag.ArrowLeft or ArrowDown subtracts one point, while ArrowRight or ArrowUp adds one.Home moves directly to 20% / 80%; End moves to 80% / 20%.leftPct in useState; pane width, visible label, and aria-valuenow must all read that value.Math.round((clientX - rect.left) / rect.width * 100), then clamp to 20..80.pointermove and pointerup on pointerdown and remove those exact function references on pointerup. The active class supplies the resize affordance.role="separator", vertical orientation, focusability, controlled pane IDs, min/max, and live value attributes synchronized.Keep one percentage in React state. Pointer and keyboard input both produce a candidate percentage, and one clamp keeps every rendered and announced value inside 20..80.
The pointer reports an absolute screen coordinate, while the pane needs a percentage inside its own container. The separator must also work without a pointer, keep tracking after the pointer leaves its narrow hit area, and stop listening as soon as the drag ends.
Treat leftPct as the model. The left width, ratio label, and separator's aria-valuenow are three views of that same number. Dragging derives a candidate from geometry; keyboard input derives one from the current value. Both routes call the same clamped setter.
Putting move and release handlers on the eight-pixel separator loses fast drags:
<div
className="divider"
onPointerMove={(event) => setLeftPct(toPercent(event.clientX))}
onPointerUp={() => setDragging(false)}
/>
Once the pointer leaves that element, it no longer receives the events. This version is also an unfocusable div, so keyboard users cannot resize the panes. The active drag listeners belong on window, while the separator itself needs its role, value attributes, focusability, and key handler.
import {
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import './styles.css';
const MIN = 20;
const MAX = 80;
const clamp = (value: number) => Math.min(MAX, Math.max(MIN, value));
export default function App() {
const [leftPct, setLeftPct] = useState(50);
const [dragging, setDragging] = useState(false);
const splitRef = useRef<HTMLDivElement>(null);
function update(value: number) {
setLeftPct(clamp(value));
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(true);
function onPointerMove(moveEvent: PointerEvent) {
const split = splitRef.current;
if (!split) return;
const rect = split.getBoundingClientRect();
const value = Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100);
update(value);
}
function onPointerUp() {
setDragging(false);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const nextByKey: Record<string, number> = {
ArrowLeft: leftPct - 1,
ArrowDown: leftPct - 1,
ArrowRight: leftPct + 1,
ArrowUp: leftPct + 1,
Home: MIN,
End: MAX,
};
const next = nextByKey[event.key];
if (next === undefined) return;
event.preventDefault();
update(next);
}
return (
<main className="container">
<h1>Resizable Split Pane</h1>
<div className="split" ref={splitRef}>
<div id="editor-pane" className="pane left" style={{ width: `${leftPct}%` }}>
<h2>Editor</h2>
<p>Drag the divider to resize. This pane is the editor.</p>
</div>
<div
className={dragging ? 'divider dragging' : 'divider'}
role="separator"
aria-label="Resize editor and preview"
aria-controls="editor-pane preview-pane"
aria-orientation="vertical"
aria-valuemin={MIN}
aria-valuemax={MAX}
aria-valuenow={leftPct}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
/>
<div id="preview-pane" className="pane right">
<h2>Preview</h2>
<p>This pane is the live preview. It fills the remaining space.</p>
</div>
</div>
<p className="label">{leftPct}% / {100 - leftPct}%</p>
</main>
);
}
The functions created for one drag are the exact functions removed by that drag's onPointerUp. React may re-render while moving, but those closures still own the matching listener pair. update is the only state gateway, so pointer positions, arrow steps, Home, and End all respect the same bounds.
onPointerDown prevents selection, shows the active resize color, and registers two window listeners.clientX = 300 when the split starts at 40 and is 440 pixels wide: the raw result is 59.1, rounded to 59 and accepted by the clamp.End after focusing the separator: the same update path stores 80; width, label, and aria-valuenow all become 80.dragging returns to false.window.aria-valuenow from leftPct.min, max, initial size, and keyboard step as props.A reducer owns both percentage and active gesture state, while each drag still owns its exact window listener pair.
import { useReducer, useRef, type KeyboardEvent } from 'react';
import './styles.css';
type State = { left: number; dragging: boolean };
function reducer(state: State, action: { type: 'size'; value: number } | { type: 'drag'; value: boolean }): State { return action.type === 'size' ? { ...state, left: Math.min(80, Math.max(20, action.value)) } : { ...state, dragging: action.value }; }
export default function App() {
const [state, dispatch] = useReducer(reducer, { left: 50, dragging: false }); const split = useRef<HTMLDivElement>(null);
const start = () => { dispatch({ type: 'drag', value: true }); const move = (event: PointerEvent) => { const rect = split.current!.getBoundingClientRect(); dispatch({ type: 'size', value: Math.round((event.clientX - rect.left) / rect.width * 100) }); }; const up = () => { dispatch({ type: 'drag', value: false }); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (event: KeyboardEvent) => { const value = ({ ArrowLeft: state.left - 1, ArrowDown: state.left - 1, ArrowRight: state.left + 1, ArrowUp: state.left + 1, Home: 20, End: 80 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); dispatch({ type: 'size', value }); } };
return <main className="container"><h1>Resizable Split Pane</h1><div className="split" ref={split}><div id="editor-pane" className="pane left" style={{ width: `${state.left}%` }}><h2>Editor</h2><p>Drag the divider to resize. This pane is the editor.</p></div><div className={state.dragging ? 'divider dragging' : 'divider'} role="separator" aria-label="Resize editor and preview" aria-controls="editor-pane preview-pane" aria-orientation="vertical" aria-valuemin={20} aria-valuemax={80} aria-valuenow={state.left} tabIndex={0} onPointerDown={start} onKeyDown={key} /><div id="preview-pane" className="pane right"><h2>Preview</h2><p>This pane is the live preview. It fills the remaining space.</p></div></div><p className="label">{state.left}% / {100 - state.left}%</p></main>;
}A focused hook owns size, clamping, and gesture state while the component keeps the same semantic layout.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
export default function App() {
const [left, setLeft] = useState(50); const [dragging, setDragging] = useState(false); const split = useRef<HTMLDivElement>(null); const update = (value: number) => setLeft(Math.min(80, Math.max(20, value)));
const start = () => { setDragging(true); const move = (event: PointerEvent) => { const rect = split.current!.getBoundingClientRect(); update(Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (event: KeyboardEvent) => { const value = ({ ArrowLeft: left - 1, ArrowDown: left - 1, ArrowRight: left + 1, ArrowUp: left + 1, Home: 20, End: 80 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(value); } };
return <main className="container"><h1>Resizable Split Pane</h1><div className="split" ref={split}><div id="editor-pane" className="pane left" style={{ width: `${left}%` }}><h2>Editor</h2><p>Drag the divider to resize. This pane is the editor.</p></div><div className={dragging ? 'divider dragging' : 'divider'} role="separator" aria-label="Resize editor and preview" aria-controls="editor-pane preview-pane" aria-orientation="vertical" aria-valuemin={20} aria-valuemax={80} aria-valuenow={left} tabIndex={0} onPointerDown={start} onKeyDown={key} /><div id="preview-pane" className="pane right"><h2>Preview</h2><p>This pane is the live preview. It fills the remaining space.</p></div></div><p className="label">{left}% / {100 - left}%</p></main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A split pane lets someone give more room to one of two adjacent regions. Build the React version with a pointer-draggable, keyboard-operable separator whose left-pane percentage stays between 20 and 80.
Implement the App component in App.tsx. It receives no props and starts with the Editor and Preview panes at 50% / 50%.
80% / 20%.pointermove and pointerup live on window only for the active drag.ArrowLeft or ArrowDown subtracts one point, while ArrowRight or ArrowUp adds one.Home moves directly to 20% / 80%; End moves to 80% / 20%.leftPct in useState; pane width, visible label, and aria-valuenow must all read that value.Math.round((clientX - rect.left) / rect.width * 100), then clamp to 20..80.pointermove and pointerup on pointerdown and remove those exact function references on pointerup. The active class supplies the resize affordance.role="separator", vertical orientation, focusability, controlled pane IDs, min/max, and live value attributes synchronized.Keep one percentage in React state. Pointer and keyboard input both produce a candidate percentage, and one clamp keeps every rendered and announced value inside 20..80.
The pointer reports an absolute screen coordinate, while the pane needs a percentage inside its own container. The separator must also work without a pointer, keep tracking after the pointer leaves its narrow hit area, and stop listening as soon as the drag ends.
Treat leftPct as the model. The left width, ratio label, and separator's aria-valuenow are three views of that same number. Dragging derives a candidate from geometry; keyboard input derives one from the current value. Both routes call the same clamped setter.
Putting move and release handlers on the eight-pixel separator loses fast drags:
<div
className="divider"
onPointerMove={(event) => setLeftPct(toPercent(event.clientX))}
onPointerUp={() => setDragging(false)}
/>
Once the pointer leaves that element, it no longer receives the events. This version is also an unfocusable div, so keyboard users cannot resize the panes. The active drag listeners belong on window, while the separator itself needs its role, value attributes, focusability, and key handler.
import {
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import './styles.css';
const MIN = 20;
const MAX = 80;
const clamp = (value: number) => Math.min(MAX, Math.max(MIN, value));
export default function App() {
const [leftPct, setLeftPct] = useState(50);
const [dragging, setDragging] = useState(false);
const splitRef = useRef<HTMLDivElement>(null);
function update(value: number) {
setLeftPct(clamp(value));
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(true);
function onPointerMove(moveEvent: PointerEvent) {
const split = splitRef.current;
if (!split) return;
const rect = split.getBoundingClientRect();
const value = Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100);
update(value);
}
function onPointerUp() {
setDragging(false);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const nextByKey: Record<string, number> = {
ArrowLeft: leftPct - 1,
ArrowDown: leftPct - 1,
ArrowRight: leftPct + 1,
ArrowUp: leftPct + 1,
Home: MIN,
End: MAX,
};
const next = nextByKey[event.key];
if (next === undefined) return;
event.preventDefault();
update(next);
}
return (
<main className="container">
<h1>Resizable Split Pane</h1>
<div className="split" ref={splitRef}>
<div id="editor-pane" className="pane left" style={{ width: `${leftPct}%` }}>
<h2>Editor</h2>
<p>Drag the divider to resize. This pane is the editor.</p>
</div>
<div
className={dragging ? 'divider dragging' : 'divider'}
role="separator"
aria-label="Resize editor and preview"
aria-controls="editor-pane preview-pane"
aria-orientation="vertical"
aria-valuemin={MIN}
aria-valuemax={MAX}
aria-valuenow={leftPct}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
/>
<div id="preview-pane" className="pane right">
<h2>Preview</h2>
<p>This pane is the live preview. It fills the remaining space.</p>
</div>
</div>
<p className="label">{leftPct}% / {100 - leftPct}%</p>
</main>
);
}
The functions created for one drag are the exact functions removed by that drag's onPointerUp. React may re-render while moving, but those closures still own the matching listener pair. update is the only state gateway, so pointer positions, arrow steps, Home, and End all respect the same bounds.
onPointerDown prevents selection, shows the active resize color, and registers two window listeners.clientX = 300 when the split starts at 40 and is 440 pixels wide: the raw result is 59.1, rounded to 59 and accepted by the clamp.End after focusing the separator: the same update path stores 80; width, label, and aria-valuenow all become 80.dragging returns to false.window.aria-valuenow from leftPct.min, max, initial size, and keyboard step as props.A reducer owns both percentage and active gesture state, while each drag still owns its exact window listener pair.
import { useReducer, useRef, type KeyboardEvent } from 'react';
import './styles.css';
type State = { left: number; dragging: boolean };
function reducer(state: State, action: { type: 'size'; value: number } | { type: 'drag'; value: boolean }): State { return action.type === 'size' ? { ...state, left: Math.min(80, Math.max(20, action.value)) } : { ...state, dragging: action.value }; }
export default function App() {
const [state, dispatch] = useReducer(reducer, { left: 50, dragging: false }); const split = useRef<HTMLDivElement>(null);
const start = () => { dispatch({ type: 'drag', value: true }); const move = (event: PointerEvent) => { const rect = split.current!.getBoundingClientRect(); dispatch({ type: 'size', value: Math.round((event.clientX - rect.left) / rect.width * 100) }); }; const up = () => { dispatch({ type: 'drag', value: false }); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (event: KeyboardEvent) => { const value = ({ ArrowLeft: state.left - 1, ArrowDown: state.left - 1, ArrowRight: state.left + 1, ArrowUp: state.left + 1, Home: 20, End: 80 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); dispatch({ type: 'size', value }); } };
return <main className="container"><h1>Resizable Split Pane</h1><div className="split" ref={split}><div id="editor-pane" className="pane left" style={{ width: `${state.left}%` }}><h2>Editor</h2><p>Drag the divider to resize. This pane is the editor.</p></div><div className={state.dragging ? 'divider dragging' : 'divider'} role="separator" aria-label="Resize editor and preview" aria-controls="editor-pane preview-pane" aria-orientation="vertical" aria-valuemin={20} aria-valuemax={80} aria-valuenow={state.left} tabIndex={0} onPointerDown={start} onKeyDown={key} /><div id="preview-pane" className="pane right"><h2>Preview</h2><p>This pane is the live preview. It fills the remaining space.</p></div></div><p className="label">{state.left}% / {100 - state.left}%</p></main>;
}A focused hook owns size, clamping, and gesture state while the component keeps the same semantic layout.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
export default function App() {
const [left, setLeft] = useState(50); const [dragging, setDragging] = useState(false); const split = useRef<HTMLDivElement>(null); const update = (value: number) => setLeft(Math.min(80, Math.max(20, value)));
const start = () => { setDragging(true); const move = (event: PointerEvent) => { const rect = split.current!.getBoundingClientRect(); update(Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (event: KeyboardEvent) => { const value = ({ ArrowLeft: left - 1, ArrowDown: left - 1, ArrowRight: left + 1, ArrowUp: left + 1, Home: 20, End: 80 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(value); } };
return <main className="container"><h1>Resizable Split Pane</h1><div className="split" ref={split}><div id="editor-pane" className="pane left" style={{ width: `${left}%` }}><h2>Editor</h2><p>Drag the divider to resize. This pane is the editor.</p></div><div className={dragging ? 'divider dragging' : 'divider'} role="separator" aria-label="Resize editor and preview" aria-controls="editor-pane preview-pane" aria-orientation="vertical" aria-valuemin={20} aria-valuemax={80} aria-valuenow={left} tabIndex={0} onPointerDown={start} onKeyDown={key} /><div id="preview-pane" className="pane right"><h2>Preview</h2><p>This pane is the live preview. It fills the remaining space.</p></div></div><p className="label">{left}% / {100 - left}%</p></main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.