Skip to content

Draggable Range Slider

A custom-styled slider with a drag handle and a value tooltip while dragging.

Freeminimalplayful
Category
Components
Added
2026-08-22
Deps
none
Updated
2026-09-01
Preview

Props

0
100
40
No runtime dependencies
"use client";

import { useEffect, useRef, useState } from "react";

export function RangeSlider({ min = 0, max = 100, defaultValue = 40 }: {
  min?: number;
  max?: number;
  defaultValue?: number;
}) {
  const trackRef = useRef<HTMLDivElement>(null);
  const [value, setValue] = useState(defaultValue);
  const [dragging, setDragging] = useState(false);
  const percent = ((value - min) / (max - min)) * 100;
  const step = 1;
  const bigStep = Math.max(step, Math.round((max - min) / 10));

  function clamp(v: number) {
    return Math.min(max, Math.max(min, v));
  }

  function updateFromClientX(clientX: number) {
    const track = trackRef.current;
    if (!track) return;
    const rect = track.getBoundingClientRect();
    const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
    setValue(Math.round(min + ratio * (max - min)));
  }

  function handleKeyDown(e: React.KeyboardEvent) {
    let delta = 0;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        delta = e.shiftKey ? bigStep : step;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        delta = -(e.shiftKey ? bigStep : step);
        break;
      case "PageUp":
        delta = bigStep;
        break;
      case "PageDown":
        delta = -bigStep;
        break;
      case "Home":
        e.preventDefault();
        setValue(min);
        return;
      case "End":
        e.preventDefault();
        setValue(max);
        return;
      default:
        return;
    }
    e.preventDefault();
    setValue((v) => clamp(v + delta));
  }

  useEffect(() => {
    if (!dragging) return;
    function handleMove(e: PointerEvent) { updateFromClientX(e.clientX); }
    function handleUp() { setDragging(false); }
    window.addEventListener("pointermove", handleMove);
    window.addEventListener("pointerup", handleUp);
    return () => {
      window.removeEventListener("pointermove", handleMove);
      window.removeEventListener("pointerup", handleUp);
    };
  }, [dragging]);

  return (
    <div className="w-full max-w-xs px-2 py-6">
      <div
        ref={trackRef}
        className="relative h-1.5 w-full cursor-pointer rounded-full bg-neutral-200"
        onPointerDown={(e) => { updateFromClientX(e.clientX); setDragging(true); }}
      >
        <div className="absolute inset-y-0 left-0 rounded-full bg-red-500" style={{ width: `${percent}%` }} />
        <div
          role="slider"
          tabIndex={0}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          aria-label="Value"
          onKeyDown={handleKeyDown}
          className="absolute top-1/2 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-red-500 bg-white shadow-md outline-none focus-visible:ring-2 focus-visible:ring-red-500"
          style={{ left: `${percent}%` }}
        >
          {dragging && (
            <span className="absolute -top-9 rounded-md bg-neutral-900 px-2 py-1 font-mono text-xs font-bold text-white">
              {value}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}
Notes

About this component

Restyling a native input of type range hits a wall fast; cross-browser thumb and track styling is inconsistent enough that most teams rebuild the whole thing, which is what this does. Position comes from a single piece of state, a percent derived from value, min, and max, that both the fill bar and the thumb read from, so they cannot visually desync the way they can when a drag library's own transform state gets layered on top of separately computed position state, a mistake the author's note calls out as something an earlier version got wrong. Dragging is wired with raw pointer events: pointerdown starts a dragging flag, a useEffect that only runs while dragging attaches window-level pointermove and pointerup listeners, and clientX maps back to a value through the track's bounding rect on every move. The thumb also carries role="slider", tabIndex, and aria-valuemin/max/now, with arrow keys stepping the value by 1 (Shift+Arrow or PageUp/PageDown by a larger step, Home/End jumping to the ends), so it is fully operable from the keyboard, not just pointer and touch.

Curator’s note

Position comes from one piece of state, read by both the fill bar and the thumb — an early version tried mixing this with Framer Motion's own drag transform for a nicer feel, and the two position sources fought each other and drifted apart on every drag. Plain pointer events kept it honest.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A draggable before/after divider that reveals one image over another.

imageslidercompare
Featured
NewPro

A numeric input whose label you drag to scrub, with pointer lock and precision modifiers.

inputnumberdrag

A text input whose label floats from placeholder position into a caption on focus.

inputformlabel