Dual Range Slider
Inputs & Forms

Dual Range Slider

A two-thumb range slider with a filled active segment, drag-time value tooltips that slide apart as the thumbs converge, and a min-gap clamp so the pair never crosses.

Install

npx shadcn@latest add @paragon/dual-range-slider

dual-range-slider.tsx

"use client";

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";

export interface DualRangeSliderProps
  extends Omit<
    React.ComponentProps<typeof SliderPrimitive.Root>,
    "value" | "defaultValue" | "onValueChange"
  > {
  /** Controlled [min, max] pair. */
  value?: [number, number];
  /** Initial [min, max] pair when uncontrolled. */
  defaultValue?: [number, number];
  onValueChange?: (value: [number, number]) => void;
  /** Smallest allowed distance between the two thumbs, in value units. */
  minGap?: number;
  /** Show value tooltips above the thumbs while dragging or focused. */
  showTooltip?: boolean;
  /** Format the tooltip value, e.g. `(v) => \`$${v}\``. */
  formatValue?: (value: number) => string;
  /** Accessible names for the two thumbs. */
  thumbLabels?: [string, string];
}

/**
 * A two-thumb range slider: the active segment fills between the thumbs,
 * value tooltips fade in while dragging and slide apart when the thumbs get
 * close, and a min-gap clamp keeps the pair from crossing.
 */
export function DualRangeSlider({
  className,
  min = 0,
  max = 100,
  step = 1,
  minGap = 0,
  value,
  defaultValue,
  onValueChange,
  showTooltip = true,
  formatValue = (v) => String(v),
  thumbLabels = ["Minimum", "Maximum"],
  ...props
}: DualRangeSliderProps) {
  const [internal, setInternal] = React.useState<[number, number]>(
    value ?? defaultValue ?? [min, max],
  );
  const values = value ?? internal;

  const [dragging, setDragging] = React.useState(false);
  React.useEffect(() => {
    if (!dragging) return;
    const stop = () => setDragging(false);
    window.addEventListener("pointerup", stop);
    window.addEventListener("pointercancel", stop);
    return () => {
      window.removeEventListener("pointerup", stop);
      window.removeEventListener("pointercancel", stop);
    };
  }, [dragging]);

  const percent = (v: number) =>
    max === min ? 0 : ((v - min) / (max - min)) * 100;

  const handleChange = (next: number[]) => {
    let [lo, hi] = next as [number, number];
    // Radix's minStepsBetweenThumbs enforces the gap; this clamp is a
    // defensive normalization for programmatic values on non-step gaps.
    if (hi - lo < minGap) {
      if (lo !== values[0]) lo = hi - minGap;
      else hi = lo + minGap;
      lo = Math.max(min, lo);
      hi = Math.min(max, hi);
    }
    const pair: [number, number] = [lo, hi];
    setInternal(pair);
    onValueChange?.(pair);
  };

  // When the thumbs converge, slide the tooltips apart so they never overlap.
  const close = percent(values[1]) - percent(values[0]) < 15;

  return (
    <SliderPrimitive.Root
      data-slot="dual-range-slider"
      min={min}
      max={max}
      step={step}
      minStepsBetweenThumbs={step > 0 ? Math.ceil(minGap / step) : 0}
      value={value ?? internal}
      onValueChange={handleChange}
      onPointerDown={() => setDragging(true)}
      className={cn(
        "relative flex w-full touch-none items-center select-none",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        className,
      )}
      {...props}
    >
      <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
        <SliderPrimitive.Range className="absolute h-full bg-primary" />
      </SliderPrimitive.Track>
      {values.map((thumbValue, index) => (
        <SliderPrimitive.Thumb
          key={index}
          aria-label={thumbLabels[index]}
          className={cn(
            "group relative block size-4 shrink-0 rounded-full border border-ring/40 bg-background shadow-sm",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
          )}
        >
          {showTooltip && (
            <span
              aria-hidden
              className={cn(
                "pointer-events-none absolute bottom-full left-1/2 mb-2",
                "rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-overlay",
                "origin-bottom transition-[opacity,scale,translate] duration-150 ease-out motion-reduce:transition-[opacity]",
                "group-focus-visible:scale-100 group-focus-visible:opacity-100",
                dragging ? "scale-100 opacity-100" : "scale-95 opacity-0",
                close
                  ? index === 0
                    ? "-translate-x-[85%]"
                    : "-translate-x-[15%]"
                  : "-translate-x-1/2",
              )}
            >
              {formatValue(thumbValue)}
            </span>
          )}
        </SliderPrimitive.Thumb>
      ))}
    </SliderPrimitive.Root>
  );
}