DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a dual-thumb range slider from scratch (no input type=range)

Every price filter you've used on Amazon, Airbnb or Booking is the same widget: a track with two handles you drag to set a minimum and a maximum, with the bit in between filled in. The native <input type="range"> only gives you one thumb, and styling it consistently across browsers is a fight, so I rebuilt the whole thing in about 80 lines of vanilla JS. Here is how the parts fit together.

value and pixel are the whole game

A slider is really one linear mapping between a value scale and the pixel width of the track. Going out, from a value to the screen, I use a percentage so the layout never needs a resize listener:

const pct = v => ((v - min) / (max - min)) * 100;  // 40 on [0,400] -> 10%
Enter fullscreen mode Exit fullscreen mode

Coming back in, from a pixel to a value, I measure the track at the moment the event fires:

function valueAt(clientX){
  const r = track.getBoundingClientRect();
  const ratio = clamp((clientX - r.left) / r.width, 0, 1);
  return snap(min + ratio * (max - min));
}
Enter fullscreen mode Exit fullscreen mode

Because rendering is in percent, 40% is 40% at any width. That means no resize handler and no re-layout math anywhere in the widget. The only time I touch real pixels is inside a pointer event, and I re-read the rect right then.

Snapping to the step

A step turns the continuous track into fixed stops. Round to the nearest number of steps from min, then clamp back into range:

const decimals = (String(step).split(".")[1] || "").length;
function snap(v){
  const s = Math.round((v - min) / step) * step + min;
  return clamp(parseFloat(s.toFixed(decimals)), min, max);
}
Enter fullscreen mode Exit fullscreen mode

The toFixed line is not decoration. A step of 0.5 will happily produce 2.7500000001, and that leaks straight into your labels. Rounding to the step's own decimal count kills it. Snapping runs on every input path, so drag, click and keyboard all land on legal values.

Two thumbs that can't cross

The dual slider stores [lo, hi], and its one rule is that the low value stays at or below the high value. I enforce it at the single point where a value changes, not with scattered checks:

function setVal(i, v){
  v = snap(v);
  if (i === 0) v = clamp(v, min, vals[1]);   // low  <= high
  else         v = clamp(v, vals[0], max);   // high >= low
  vals[i] = v; render();
}
Enter fullscreen mode Exit fullscreen mode

Push one thumb into the other and it simply stops at the boundary. Every path that moves a thumb goes through this function, so drag, tap and keyboard all respect the constraint automatically.

One pointer path for mouse and touch

Pointer events cover mouse, touch and pen in one stream, so there is no separate touch branch. The trick that makes drag feel solid is setPointerCapture: it keeps sending moves to the thumb even when your finger slides off it.

thumb.addEventListener("pointerdown", e => {
  e.preventDefault();
  thumb.setPointerCapture(e.pointerId);
  dragging = i;
});
thumb.addEventListener("pointermove", e => {
  if (dragging === i) setVal(i, valueAt(e.clientX));
});
Enter fullscreen mode Exit fullscreen mode

Add touch-action: none in CSS so a drag on a touchscreen does not scroll the page out from under you.

Click the track, grab the nearest thumb

People tap a spot on the track and expect the nearest handle to jump there. Work out the value at the click, pick the closer thumb, move it, then hand the pointer to that thumb so the same press turns into a drag:

track.addEventListener("pointerdown", e => {
  if (e.target.closest(".rs-thumb")) return;
  const v = valueAt(e.clientX);
  const i = Math.abs(v - vals[0]) <= Math.abs(v - vals[1]) ? 0 : 1;
  setVal(i, v);
  thumbs[i].setPointerCapture(e.pointerId);
});
Enter fullscreen mode Exit fullscreen mode

Keyboard and ARIA

Each thumb is a focus stop with role="slider". Arrows move by the step, Shift+arrow and PageUp/PageDown move by a big step, and Home/End go to that thumb's own limits, which keeps the no-cross rule for free. For screen readers, publish aria-valuenow plus a human aria-valuetext like "$40", and reflect the live limits: the low thumb's aria-valuemax is the current high, and the high thumb's aria-valuemin is the current low. Update all of it inside render() so it never drifts from what is on screen.

A couple of edge cases finish it off: flip the ratio when the track lays out right-to-left, and fall back to a span of 1 when min === max so you never divide by zero.

That is the whole thing: a two-way value map, step snapping, a no-cross clamp, unified pointer drag, click-to-nearest, and live slider ARIA. No framework, no dependency, and it behaves the same on a laptop and a phone.

Live demo, with the interactive walkthrough and copy-paste build steps: https://dev48v.infy.uk/design/day26-range-slider.html

Top comments (0)