Skip to content

SliderRange

MD · 1950B gzip budget

Dual-thumb range slider with locale-formatted values.

Category
Forms & input
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
Range

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/slider-range.json

Copies the source into your project. Pulls in 1 shared registry item: cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useId, useRef, useState } from "react";
import { cn } from "../lib/cn";

export interface SliderRangeProps {
  min: number;
  max: number;
  minVal: number;
  maxVal: number;
  onChange: (min: number, max: number) => void;
  label: string;
  step?: number;
  className?: string;
}

export function SliderRange({ min, max, minVal, maxVal, onChange, label, step = 1, className }: SliderRangeProps) {
  const id = useId();
  const trackRef = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState<"min" | "max" | null>(null);

  const toPercent = (val: number) => ((val - min) / (max - min)) * 100;
  const fromPercent = (pct: number) => {
    const raw = min + (pct / 100) * (max - min);
    return Math.round(raw / step) * step;
  };

  const handlePointerDown = (thumb: "min" | "max") => (e: React.PointerEvent) => {
    e.preventDefault();
    setDragging(thumb);
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!dragging || !trackRef.current) return;
    const rect = trackRef.current.getBoundingClientRect();
    const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
    const val = Math.round(fromPercent(pct) / step) * step;

    if (dragging === "min") {
      onChange(Math.min(val, maxVal - step), maxVal);
    } else {
      onChange(minVal, Math.max(val, minVal + step));
    }
  };

  const handlePointerUp = () => setDragging(null);

  const handleKeyDown = (thumb: "min" | "max") => (e: React.KeyboardEvent) => {
    const val = thumb === "min" ? minVal : maxVal;
    let next = val;

    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      e.preventDefault();
      next = Math.min(max, val + step);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      e.preventDefault();
      next = Math.max(min, val - step);
    } else if (e.key === "Home") {
      e.preventDefault();
      next = min;
    } else if (e.key === "End") {
      e.preventDefault();
      next = max;
    }

    if (thumb === "min") {
      onChange(Math.min(next, maxVal - step), maxVal);
    } else {
      onChange(minVal, Math.max(next, minVal + step));
    }
  };

  return (
    <div className={cn("flex flex-col gap-1.5 text-start", className)}>
      <div className="flex justify-between text-sm">
        <span id={id}>{label}</span>
        <span aria-hidden="true">{minVal} – {maxVal}</span>
      </div>
      <div
        ref={trackRef}
        role="group"
        aria-labelledby={id}
        className="relative h-2 cursor-pointer"
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <div className="absolute inset-y-1/2 h-1 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-800" />
        <div
          className="absolute inset-y-1/2 h-1 -translate-y-1/2 rounded-full bg-blue-600"
          style={{ left: `${toPercent(minVal)}%`, right: `${100 - toPercent(maxVal)}%` }}
        />
        <input
          type="range"
          aria-label={`${label} minimum`}
          aria-valuemin={min}
          aria-valuemax={maxVal - step}
          aria-valuenow={minVal}
          min={min}
          max={maxVal - step}
          step={step}
          value={minVal}
          onChange={(e) => onChange(Number(e.target.value), maxVal)}
          className="absolute inset-0 size-full opacity-0"
          style={{ pointerEvents: "none" }}
        />
        <div
          role="slider"
          aria-label={`${label} minimum`}
          aria-valuemin={min}
          aria-valuemax={maxVal - step}
          aria-valuenow={minVal}
          tabIndex={0}
          onPointerDown={handlePointerDown("min")}
          onKeyDown={handleKeyDown("min")}
          className="absolute top-1/2 z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-blue-600 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:bg-neutral-950"
          style={{ left: `${toPercent(minVal)}%` }}
        />
        <div
          role="slider"
          aria-label={`${label} maximum`}
          aria-valuemin={minVal + step}
          aria-valuemax={max}
          aria-valuenow={maxVal}
          tabIndex={0}
          onPointerDown={handlePointerDown("max")}
          onKeyDown={handleKeyDown("max")}
          className="absolute top-1/2 z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-blue-600 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:bg-neutral-950"
          style={{ left: `${toPercent(maxVal)}%` }}
        />
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation