# Range Slider

> Dual-thumb range slider with a minimum gap and keyboard control, ideal for price filters.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/range-slider
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/range-slider.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/range-slider-demo.tsx"
"use client"

import * as React from "react"

import { RangeSlider } from "@/components/range-slider"

export default function RangeSliderDemo() {
  const [price, setPrice] = React.useState<[number, number]>([200, 800])
  return (
    <div className="grid w-full max-w-sm gap-5 rounded-2xl border bg-card p-5">
      <div className="flex items-baseline justify-between">
        <h3 className="text-sm font-semibold">Price range</h3>
        <span className="text-sm tabular-nums text-muted-foreground" data-testid="range">
          ${price[0]} – ${price[1]}
        </span>
      </div>
      <RangeSlider value={price} onValueChange={setPrice} min={0} max={1000} step={10} minGap={50} />
      <div className="flex justify-between text-xs text-muted-foreground">
        <span>$0</span>
        <span>$1,000</span>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/range-slider.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface RangeSliderProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: [number, number]
  defaultValue?: [number, number]
  onValueChange?: (value: [number, number]) => void
  min?: number
  max?: number
  step?: number
  /** Smallest allowed distance between the two thumbs */
  minGap?: number
  disabled?: boolean
}

export function RangeSlider({
  value,
  defaultValue = [25, 75],
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  minGap = 0,
  disabled = false,
  className,
  ...props
}: RangeSliderProps) {
  const [internal, setInternal] = React.useState<[number, number]>(defaultValue)
  const track = React.useRef<HTMLDivElement>(null)
  const thumbs = React.useRef<(HTMLDivElement | null)[]>([])
  const dragging = React.useRef<0 | 1 | null>(null)
  const [activeThumb, setActiveThumb] = React.useState<0 | 1 | null>(null)

  const [low, high] = value ?? internal
  const percentOf = (n: number) => (max === min ? 0 : ((n - min) / (max - min)) * 100)

  const commit = (index: 0 | 1, raw: number) => {
    const snapped = Number((Math.round((raw - min) / step) * step + min).toFixed(6))
    const next: [number, number] = [low, high]
    next[index] = index === 0 ? Math.min(Math.max(min, snapped), high - minGap) : Math.max(Math.min(max, snapped), low + minGap)
    if (next[0] === low && next[1] === high) return
    setInternal(next)
    onValueChange?.(next)
  }

  const valueAt = (clientX: number) => {
    const rect = track.current?.getBoundingClientRect()
    if (!rect || rect.width === 0) return null
    return min + ((clientX - rect.left) / rect.width) * (max - min)
  }

  const onKeyDown = (index: 0 | 1) => (event: React.KeyboardEvent) => {
    if (disabled) return
    const current = index === 0 ? low : high
    const big = step * 10
    const map: Record<string, number> = {
      ArrowRight: current + step,
      ArrowUp: current + step,
      ArrowLeft: current - step,
      ArrowDown: current - step,
      PageUp: current + big,
      PageDown: current - big,
      Home: index === 0 ? min : low + minGap,
      End: index === 0 ? high - minGap : max,
    }
    if (event.key in map) {
      event.preventDefault()
      commit(index, map[event.key])
    }
  }

  const stop = () => {
    dragging.current = null
    setActiveThumb(null)
  }

  return (
    <div
      {...props}
      className={cn("relative flex h-6 w-full touch-none select-none items-center", disabled && "opacity-50", className)}
      onPointerDown={(event) => {
        if (disabled || event.button !== 0) return
        const target = valueAt(event.clientX)
        if (target === null) return
        const index: 0 | 1 = Math.abs(target - low) <= Math.abs(target - high) && !(target > high) ? 0 : 1
        event.currentTarget.setPointerCapture(event.pointerId)
        dragging.current = index
        setActiveThumb(index)
        thumbs.current[index]?.focus()
        commit(index, target)
      }}
      onPointerMove={(event) => {
        if (dragging.current === null) return
        const target = valueAt(event.clientX)
        if (target !== null) commit(dragging.current, target)
      }}
      onPointerUp={stop}
      onPointerCancel={stop}
    >
      <div ref={track} className="relative h-1.5 w-full rounded-full bg-muted">
        <div
          className="absolute inset-y-0 rounded-full bg-primary"
          style={{ left: `${percentOf(low)}%`, width: `${percentOf(high) - percentOf(low)}%` }}
        />
        {([low, high] as const).map((current, index) => (
          <div
            key={index}
            ref={(el) => {
              thumbs.current[index] = el
            }}
            role="slider"
            tabIndex={disabled ? -1 : 0}
            aria-label={index === 0 ? "Minimum" : "Maximum"}
            aria-valuemin={index === 0 ? min : low + minGap}
            aria-valuemax={index === 0 ? high - minGap : max}
            aria-valuenow={current}
            onKeyDown={onKeyDown(index as 0 | 1)}
            className={cn(
              "absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm outline-none transition-transform focus-visible:ring-4 focus-visible:ring-ring/40",
              activeThumb === index && "scale-110",
            )}
            style={{ left: `${percentOf(current)}%`, zIndex: activeThumb === index ? 2 : 1 }}
          />
        ))}
      </div>
    </div>
  )
}
```
