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.
npx shadcn@latest add @paragon/dual-range-slider"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>
);
}