A dual-handle range slider lets someone choose the lower and upper bounds of an interval on one track. Build it as a React component with pointer and keyboard input, integer values from 0 to 100, a highlighted segment, and a label that always reflects the ordered range.
Implement the App component in App.tsx. It receives no props and renders two focusable slider handles whose low and high values start at 20 and 80.
20 – 80 below. Dragging the left handle right to the middle updates the label to 50 – 80 and shrinks the green bar.window.ArrowLeft changes 20 – 80 to 20 – 79; pressing Home stops it at the low value, 20.low and high in useState, and read the track through a useRef.pointermove and pointerup listeners on window, then remove both on release so fast drags keep tracking without leaking handlers.Math.round((clientX - left) / width * 100) and clamp the result to 0..100.low <= high always holds.role="slider" focusable; Arrow keys move by one, Home moves to its allowed minimum, and End moves to its allowed maximum.aria-valuenow and each handle's live ARIA boundary synchronized with state.Two numbers, low and high, live in state. Every pointer move recomputes one of them from the pointer's X position, and a clamp keeps the handles from crossing.
A range slider is two sliders sharing one track. The user grabs a handle and drags; you have to translate where the pointer is on screen into a value from 0 to 100, snap it to a whole number, and refuse to let the low handle pass the high one (or vice versa). Everything visible — each handle's position, the green bar, the label — is derived from just low and high.
The track is a ruler. The pointer reports an absolute screen X (clientX), but you want a value relative to the track. Subtract the track's left edge, divide by its width, and you get a fraction; times 100 and rounded, that's the value. Then the handle's CSS left is simply that value as a percent — position and value are the same number.
A common first try puts the listeners on the handle itself:
<div
className="handle"
onPointerDown={() => setDragging(true)}
onPointerMove={(e) => {
if (!dragging) return;
setLow(Math.round((e.clientX - rect.left) / rect.width * 100));
}}
/>
It works until you drag fast. The moment the pointer outruns the 18px handle, pointermove stops firing on it and the handle freezes mid-drag. There's also no clamp, so the low handle sails straight past the high one and the range inverts.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
export default function App() {
const [low, setLow] = useState(20);
const [high, setHigh] = useState(80);
const trackRef = useRef<HTMLDivElement>(null);
function setHandleValue(which: 'low' | 'high', value: number) {
if (which === 'low') setLow(Math.min(value, high));
else setHigh(Math.max(value, low));
}
function startDrag(which: 'low' | 'high') {
function onMove(e: PointerEvent) {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const pct = Math.round(((e.clientX - rect.left) / rect.width) * 100);
const value = Math.max(0, Math.min(100, pct));
setHandleValue(which, value);
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onKeyDown(which: 'low' | 'high', e: KeyboardEvent<HTMLDivElement>) {
const current = which === 'low' ? low : high;
const nextByKey: Record<string, number> = {
ArrowLeft: current - 1,
ArrowDown: current - 1,
ArrowRight: current + 1,
ArrowUp: current + 1,
Home: 0,
End: 100,
};
const next = nextByKey[e.key];
if (next === undefined) return;
e.preventDefault();
setHandleValue(which, next);
}
return (
<main className="container">
<h1>Range Slider</h1>
<div className="slider">
<div className="track" ref={trackRef}>
<div className="range" style={{ left: `${low}%`, width: `${high - low}%` }} />
<div
className="handle"
role="slider"
aria-label="Minimum"
aria-valuemin={0}
aria-valuemax={high}
aria-valuenow={low}
tabIndex={0}
style={{ left: `${low}%` }}
onPointerDown={() => startDrag('low')}
onKeyDown={(e) => onKeyDown('low', e)}
/>
<div
className="handle"
role="slider"
aria-label="Maximum"
aria-valuemin={low}
aria-valuemax={100}
aria-valuenow={high}
tabIndex={0}
style={{ left: `${high}%` }}
onPointerDown={() => startDrag('high')}
onKeyDown={(e) => onKeyDown('high', e)}
/>
</div>
</div>
<p className="values">{low} – {high}</p>
</main>
);
}
The listeners move to window, added on pointerdown and removed on pointerup, so a fast or off-handle drag still tracks. setHandleValue centralizes the crossing rule for both pointer and keyboard input. tabIndex={0} makes each custom slider reachable, while the key map provides Arrow, Home, and End behavior.
startDrag('low') runs and attaches onMove + onUp to window.clientX = 320 over a track whose left is 120 and width is 300: (320 - 120) / 300 * 100 = 66.6, rounded to 67. Since 67 < high (80), setLow(67).left is now 67%, the green bar is 67%-wide-minus-offset, the label reads 67 – 80.onUp removes both listeners. The next drag re-adds them.The clamp is the one line that makes this a range slider rather than two independent sliders. When you drag low, setLow(Math.min(value, high)) caps it at the current high; drag high and setHigh(Math.max(value, low)) floors it at low. Push the low handle toward 90 while high sits at 80 and it simply stops at 80.
onPointerMove on the 18px handle drops the drag the instant the pointer outpaces it. Attach pointermove/pointerup to window for the duration of the drag.pointerup doesn't removeEventListener, every drag stacks another live pointermove on window and later handles move on their own. Always tear down in onUp.min/max against the other handle, the handles cross and high - low goes negative, so the green bar vanishes or wraps.role="slider" only announces intent. Add focusability, Arrow/Home/End handlers, and keep aria-valuenow synchronized.min, max, and step props and snap to Math.round(value / step) * step instead of whole numbers.setPointerCapture — call e.currentTarget.setPointerCapture(e.pointerId) on pointerdown to route moves to the handle, an alternative to window listeners.The reducer owns crossing rules for both pointer and keyboard input, while the component retains the same accessible render.
import { useRef, useReducer, type KeyboardEvent } from 'react';
import './styles.css';
type Which = 'low' | 'high';
type State = { low: number; high: number };
function reducer(state: State, action: { which: Which; value: number }): State {
return action.which === 'low' ? { ...state, low: Math.min(action.value, state.high) } : { ...state, high: Math.max(action.value, state.low) };
}
export default function App() {
const [state, set] = useReducer(reducer, { low: 20, high: 80 });
const track = useRef<HTMLDivElement>(null);
const update = (which: Which, value: number) => set({ which, value: Math.max(0, Math.min(100, value)) });
const drag = (which: Which) => { const move = (event: PointerEvent) => { const rect = track.current!.getBoundingClientRect(); update(which, Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (which: Which, event: KeyboardEvent) => { const current = which === 'low' ? state.low : state.high; const value = ({ ArrowLeft: current - 1, ArrowDown: current - 1, ArrowRight: current + 1, ArrowUp: current + 1, Home: 0, End: 100 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(which, value); } };
return <main className="container"><h1>Range Slider</h1><div className="slider"><div className="track" ref={track}><div className="range" style={{ left: `${state.low}%`, width: `${state.high - state.low}%` }} /><div className="handle" role="slider" aria-label="Minimum" aria-valuemin={0} aria-valuemax={state.high} aria-valuenow={state.low} tabIndex={0} style={{ left: `${state.low}%` }} onPointerDown={() => drag('low')} onKeyDown={(event) => key('low', event)} /><div className="handle" role="slider" aria-label="Maximum" aria-valuemin={state.low} aria-valuemax={100} aria-valuenow={state.high} tabIndex={0} style={{ left: `${state.high}%` }} onPointerDown={() => drag('high')} onKeyDown={(event) => key('high', event)} /></div></div><p className="values">{state.low} – {state.high}</p></main>;
}A custom hook exposes bounded values and one keyboard command, keeping range policy separate from the visual component.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
type Which = 'low' | 'high';
export default function App() {
const [low, setLow] = useState(20); const [high, setHigh] = useState(80); const track = useRef<HTMLDivElement>(null);
const update = (which: Which, value: number) => which === 'low' ? setLow(Math.min(Math.max(0, value), high)) : setHigh(Math.max(Math.min(100, value), low));
const key = (which: Which, event: KeyboardEvent) => { const current = which === 'low' ? low : high; const value = ({ ArrowLeft: current - 1, ArrowDown: current - 1, ArrowRight: current + 1, ArrowUp: current + 1, Home: 0, End: 100 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(which, value); } };
const drag = (which: Which) => { const move = (event: PointerEvent) => { const rect = track.current!.getBoundingClientRect(); update(which, Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
return <main className="container"><h1>Range Slider</h1><div className="slider"><div className="track" ref={track}><div className="range" style={{ left: `${low}%`, width: `${high - low}%` }} /><div className="handle" role="slider" aria-label="Minimum" aria-valuemin={0} aria-valuemax={high} aria-valuenow={low} tabIndex={0} style={{ left: `${low}%` }} onPointerDown={() => drag('low')} onKeyDown={(event) => key('low', event)} /><div className="handle" role="slider" aria-label="Maximum" aria-valuemin={low} aria-valuemax={100} aria-valuenow={high} tabIndex={0} style={{ left: `${high}%` }} onPointerDown={() => drag('high')} onKeyDown={(event) => key('high', event)} /></div></div><p className="values">{low} – {high}</p></main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A dual-handle range slider lets someone choose the lower and upper bounds of an interval on one track. Build it as a React component with pointer and keyboard input, integer values from 0 to 100, a highlighted segment, and a label that always reflects the ordered range.
Implement the App component in App.tsx. It receives no props and renders two focusable slider handles whose low and high values start at 20 and 80.
20 – 80 below. Dragging the left handle right to the middle updates the label to 50 – 80 and shrinks the green bar.window.ArrowLeft changes 20 – 80 to 20 – 79; pressing Home stops it at the low value, 20.low and high in useState, and read the track through a useRef.pointermove and pointerup listeners on window, then remove both on release so fast drags keep tracking without leaking handlers.Math.round((clientX - left) / width * 100) and clamp the result to 0..100.low <= high always holds.role="slider" focusable; Arrow keys move by one, Home moves to its allowed minimum, and End moves to its allowed maximum.aria-valuenow and each handle's live ARIA boundary synchronized with state.Two numbers, low and high, live in state. Every pointer move recomputes one of them from the pointer's X position, and a clamp keeps the handles from crossing.
A range slider is two sliders sharing one track. The user grabs a handle and drags; you have to translate where the pointer is on screen into a value from 0 to 100, snap it to a whole number, and refuse to let the low handle pass the high one (or vice versa). Everything visible — each handle's position, the green bar, the label — is derived from just low and high.
The track is a ruler. The pointer reports an absolute screen X (clientX), but you want a value relative to the track. Subtract the track's left edge, divide by its width, and you get a fraction; times 100 and rounded, that's the value. Then the handle's CSS left is simply that value as a percent — position and value are the same number.
A common first try puts the listeners on the handle itself:
<div
className="handle"
onPointerDown={() => setDragging(true)}
onPointerMove={(e) => {
if (!dragging) return;
setLow(Math.round((e.clientX - rect.left) / rect.width * 100));
}}
/>
It works until you drag fast. The moment the pointer outruns the 18px handle, pointermove stops firing on it and the handle freezes mid-drag. There's also no clamp, so the low handle sails straight past the high one and the range inverts.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
export default function App() {
const [low, setLow] = useState(20);
const [high, setHigh] = useState(80);
const trackRef = useRef<HTMLDivElement>(null);
function setHandleValue(which: 'low' | 'high', value: number) {
if (which === 'low') setLow(Math.min(value, high));
else setHigh(Math.max(value, low));
}
function startDrag(which: 'low' | 'high') {
function onMove(e: PointerEvent) {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const pct = Math.round(((e.clientX - rect.left) / rect.width) * 100);
const value = Math.max(0, Math.min(100, pct));
setHandleValue(which, value);
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onKeyDown(which: 'low' | 'high', e: KeyboardEvent<HTMLDivElement>) {
const current = which === 'low' ? low : high;
const nextByKey: Record<string, number> = {
ArrowLeft: current - 1,
ArrowDown: current - 1,
ArrowRight: current + 1,
ArrowUp: current + 1,
Home: 0,
End: 100,
};
const next = nextByKey[e.key];
if (next === undefined) return;
e.preventDefault();
setHandleValue(which, next);
}
return (
<main className="container">
<h1>Range Slider</h1>
<div className="slider">
<div className="track" ref={trackRef}>
<div className="range" style={{ left: `${low}%`, width: `${high - low}%` }} />
<div
className="handle"
role="slider"
aria-label="Minimum"
aria-valuemin={0}
aria-valuemax={high}
aria-valuenow={low}
tabIndex={0}
style={{ left: `${low}%` }}
onPointerDown={() => startDrag('low')}
onKeyDown={(e) => onKeyDown('low', e)}
/>
<div
className="handle"
role="slider"
aria-label="Maximum"
aria-valuemin={low}
aria-valuemax={100}
aria-valuenow={high}
tabIndex={0}
style={{ left: `${high}%` }}
onPointerDown={() => startDrag('high')}
onKeyDown={(e) => onKeyDown('high', e)}
/>
</div>
</div>
<p className="values">{low} – {high}</p>
</main>
);
}
The listeners move to window, added on pointerdown and removed on pointerup, so a fast or off-handle drag still tracks. setHandleValue centralizes the crossing rule for both pointer and keyboard input. tabIndex={0} makes each custom slider reachable, while the key map provides Arrow, Home, and End behavior.
startDrag('low') runs and attaches onMove + onUp to window.clientX = 320 over a track whose left is 120 and width is 300: (320 - 120) / 300 * 100 = 66.6, rounded to 67. Since 67 < high (80), setLow(67).left is now 67%, the green bar is 67%-wide-minus-offset, the label reads 67 – 80.onUp removes both listeners. The next drag re-adds them.The clamp is the one line that makes this a range slider rather than two independent sliders. When you drag low, setLow(Math.min(value, high)) caps it at the current high; drag high and setHigh(Math.max(value, low)) floors it at low. Push the low handle toward 90 while high sits at 80 and it simply stops at 80.
onPointerMove on the 18px handle drops the drag the instant the pointer outpaces it. Attach pointermove/pointerup to window for the duration of the drag.pointerup doesn't removeEventListener, every drag stacks another live pointermove on window and later handles move on their own. Always tear down in onUp.min/max against the other handle, the handles cross and high - low goes negative, so the green bar vanishes or wraps.role="slider" only announces intent. Add focusability, Arrow/Home/End handlers, and keep aria-valuenow synchronized.min, max, and step props and snap to Math.round(value / step) * step instead of whole numbers.setPointerCapture — call e.currentTarget.setPointerCapture(e.pointerId) on pointerdown to route moves to the handle, an alternative to window listeners.The reducer owns crossing rules for both pointer and keyboard input, while the component retains the same accessible render.
import { useRef, useReducer, type KeyboardEvent } from 'react';
import './styles.css';
type Which = 'low' | 'high';
type State = { low: number; high: number };
function reducer(state: State, action: { which: Which; value: number }): State {
return action.which === 'low' ? { ...state, low: Math.min(action.value, state.high) } : { ...state, high: Math.max(action.value, state.low) };
}
export default function App() {
const [state, set] = useReducer(reducer, { low: 20, high: 80 });
const track = useRef<HTMLDivElement>(null);
const update = (which: Which, value: number) => set({ which, value: Math.max(0, Math.min(100, value)) });
const drag = (which: Which) => { const move = (event: PointerEvent) => { const rect = track.current!.getBoundingClientRect(); update(which, Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
const key = (which: Which, event: KeyboardEvent) => { const current = which === 'low' ? state.low : state.high; const value = ({ ArrowLeft: current - 1, ArrowDown: current - 1, ArrowRight: current + 1, ArrowUp: current + 1, Home: 0, End: 100 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(which, value); } };
return <main className="container"><h1>Range Slider</h1><div className="slider"><div className="track" ref={track}><div className="range" style={{ left: `${state.low}%`, width: `${state.high - state.low}%` }} /><div className="handle" role="slider" aria-label="Minimum" aria-valuemin={0} aria-valuemax={state.high} aria-valuenow={state.low} tabIndex={0} style={{ left: `${state.low}%` }} onPointerDown={() => drag('low')} onKeyDown={(event) => key('low', event)} /><div className="handle" role="slider" aria-label="Maximum" aria-valuemin={state.low} aria-valuemax={100} aria-valuenow={state.high} tabIndex={0} style={{ left: `${state.high}%` }} onPointerDown={() => drag('high')} onKeyDown={(event) => key('high', event)} /></div></div><p className="values">{state.low} – {state.high}</p></main>;
}A custom hook exposes bounded values and one keyboard command, keeping range policy separate from the visual component.
import { useRef, useState, type KeyboardEvent } from 'react';
import './styles.css';
type Which = 'low' | 'high';
export default function App() {
const [low, setLow] = useState(20); const [high, setHigh] = useState(80); const track = useRef<HTMLDivElement>(null);
const update = (which: Which, value: number) => which === 'low' ? setLow(Math.min(Math.max(0, value), high)) : setHigh(Math.max(Math.min(100, value), low));
const key = (which: Which, event: KeyboardEvent) => { const current = which === 'low' ? low : high; const value = ({ ArrowLeft: current - 1, ArrowDown: current - 1, ArrowRight: current + 1, ArrowUp: current + 1, Home: 0, End: 100 } as Record<string, number>)[event.key]; if (value !== undefined) { event.preventDefault(); update(which, value); } };
const drag = (which: Which) => { const move = (event: PointerEvent) => { const rect = track.current!.getBoundingClientRect(); update(which, Math.round((event.clientX - rect.left) / rect.width * 100)); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); };
return <main className="container"><h1>Range Slider</h1><div className="slider"><div className="track" ref={track}><div className="range" style={{ left: `${low}%`, width: `${high - low}%` }} /><div className="handle" role="slider" aria-label="Minimum" aria-valuemin={0} aria-valuemax={high} aria-valuenow={low} tabIndex={0} style={{ left: `${low}%` }} onPointerDown={() => drag('low')} onKeyDown={(event) => key('low', event)} /><div className="handle" role="slider" aria-label="Maximum" aria-valuemin={low} aria-valuemax={100} aria-valuenow={high} tabIndex={0} style={{ left: `${high}%` }} onPointerDown={() => drag('high')} onKeyDown={(event) => key('high', event)} /></div></div><p className="values">{low} – {high}</p></main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.