shadimage
persianlabsui
uielastic-range-slidersource: ok · refreshed

Elastic Range Slider

A single-track, dual-thumb range slider in the same visual language as Elastic Slider.

npx shadcn add "https://ui.persian-labs.ir/r/elastic-range-slider.json"
free

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

utils

Files2 · source cached 10/8/2026, 3:33:26 AM

registry/base/ui/elastic-range-slider.tsx
"use client"

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react"

import { useControllableState } from "@/hooks/use-controllable-state"
import { cn } from "@/lib/utils"

// Drag detection & rubber band (only at the absolute min/max edges — the
// two thumbs clamping against each other is a hard boundary, not elastic).
const CLICK_THRESHOLD = 3
const DEAD_ZONE = 32
const MAX_CURSOR_RANGE = 200
const MAX_STRETCH = 8

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

function decimalsForStep(step: number): number {
  const s = step.toString()
  const dot = s.indexOf(".")
  return dot === -1 ? 0 : s.length - dot - 1
}

function roundValue(val: number, step: number): number {
  const raw = Math.round(val / step) * step
  return parseFloat(raw.toFixed(decimalsForStep(step)))
}

type Thumb = "min" | "max"

export type ElasticRangeSliderProps = {
  /** Label shown at the start of the track. */
  label: string

  /** Controlled [min, max] value. Use together with `onValueChange` */
  value?: [number, number]
  /** Initial [min, max] value for uncontrolled mode. Falls back to `[min, max]` */
  defaultValue?: [number, number]
  /** Called with the new [min, max] on drag, click, or key press. */
  onValueChange?: (value: [number, number]) => void

  /**
   * Minimum value.
   * @defaultValue 0 */
  min?: number
  /**
   * Maximum value.
   * @defaultValue 100 */
  max?: number
  /**
   * Smallest increment.
   * @defaultValue 1 */
  step?: number
  /** Format each displayed value. Defaults to `value.toFixed(...)` based on `step` */
  formatValue?: (value: number) => string

  className?: string
  /** Accessible name for the lower thumb. @defaultValue "Minimum" */
  minThumbLabel?: string
  /** Accessible name for the upper thumb. @defaultValue "Maximum" */
  maxThumbLabel?: string
}

export function ElasticRangeSlider({
  label,

  value: valueProp,
  defaultValue,
  onValueChange,

  min: absoluteMin = 0,
  max: absoluteMax = 100,
  step = 1,
  formatValue,

  className,
  minThumbLabel = "Minimum",
  maxThumbLabel = "Maximum",
}: ElasticRangeSliderProps) {
  const [range = [absoluteMin, absoluteMax], setRange] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue ?? [absoluteMin, absoluteMax],
    onChange: onValueChange,
  })
  const [min, max] = range

  const shouldReduceMotion = useReducedMotion()

  const wrapperRef = useRef<HTMLDivElement>(null)
  const trackRef = useRef<HTMLDivElement>(null)

  const [isHovered, setIsHovered] = useState(false)
  const [isDragging, setIsDragging] = useState(false)
  const [keyboardFocusThumb, setKeyboardFocusThumb] = useState<Thumb | null>(
    null
  )
  const isActive = isHovered || isDragging

  // Pointer session state — mutable, does not trigger re-renders.
  const draggingThumb = useRef<Thumb | null>(null)
  const pointerDownPos = useRef<{ x: number; y: number } | null>(null)
  const isClickRef = useRef(true)
  const wrapperRectRef = useRef<DOMRect | null>(null)
  const scaleRef = useRef(1)
  const minAnimRef = useRef<ReturnType<typeof animate> | null>(null)
  const maxAnimRef = useRef<ReturnType<typeof animate> | null>(null)

  const percentFromValue = useCallback(
    (v: number) => ((v - absoluteMin) / (absoluteMax - absoluteMin)) * 100,
    [absoluteMin, absoluteMax]
  )

  const displayMin = formatValue
    ? formatValue(min)
    : min.toFixed(decimalsForStep(step))
  const displayMax = formatValue
    ? formatValue(max)
    : max.toFixed(decimalsForStep(step))

  // Each thumb gets its own motion value so drags stay perfectly in sync
  // frame-to-frame, instead of both re-deriving from combined React state.
  const minPercent = useMotionValue(percentFromValue(min))
  const maxPercent = useMotionValue(percentFromValue(max))
  const minLeft = useTransform(
    minPercent,
    (pct) => `max(4px, min(calc(100% - 8px), calc(${pct}% - 4px)))`
  )
  const maxLeft = useTransform(
    maxPercent,
    (pct) => `max(4px, min(calc(100% - 8px), calc(${pct}% - 4px)))`
  )
  const fillLeft = useTransform(minPercent, (pct) => `${pct}%`)
  const fillRight = useTransform(maxPercent, (pct) => `${100 - pct}%`)

  // Rubber band at the absolute edges only.
  const rubberStretch = useMotionValue(0)
  const rubberWidth = useTransform(
    rubberStretch,
    (s) => `calc(100% + ${Math.abs(s)}px)`
  )
  // Track is anchored at the reading start (inset-inline-start: 0), so the
  // shift is direction-aware: LTR pins the track's physical left (overshoot
  // left shifts: x = s, s < 0); RTL pins the physical right (overshoot right
  // shifts: x = s, s > 0). Only the overshot edge stretches.
  const dirRef = useRef<"ltr" | "rtl">("ltr")

  useLayoutEffect(() => {
    function updateDir() {
      if (!wrapperRef.current) return
      dirRef.current =
        getComputedStyle(wrapperRef.current).direction === "rtl" ? "rtl" : "ltr"
    }

    updateDir()

    const observer = new MutationObserver(updateDir)
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["dir"],
      subtree: true,
    })

    return () => observer.disconnect()
  }, [])

  const rubberX = useTransform(rubberStretch, (s) => {
    if (s === 0) return 0
    const overshootPastAnchor = dirRef.current === "rtl" ? s > 0 : s < 0
    return overshootPastAnchor ? s : 0
  })

  // Sync from props when not actively dragging that thumb.
  useEffect(() => {
    if (draggingThumb.current !== "min" && !minAnimRef.current) {
      minPercent.jump(percentFromValue(min))
    }
  }, [min, percentFromValue, minPercent])

  useEffect(() => {
    if (draggingThumb.current !== "max" && !maxAnimRef.current) {
      maxPercent.jump(percentFromValue(max))
    }
  }, [max, percentFromValue, maxPercent])

  const positionToValue = useCallback(
    (clientX: number) => {
      const rect = wrapperRectRef.current
      if (!rect) return absoluteMin

      const sceneX = (clientX - rect.left) / scaleRef.current
      const nativeWidth = wrapperRef.current?.offsetWidth ?? rect.width
      const percent = clamp(sceneX / nativeWidth, 0, 1)

      return clamp(
        absoluteMin + percent * (absoluteMax - absoluteMin),
        absoluteMin,
        absoluteMax
      )
    },
    [absoluteMin, absoluteMax]
  )

  const animateThumbTo = useCallback(
    (thumb: Thumb, targetPercent: number) => {
      const motionValue = thumb === "min" ? minPercent : maxPercent
      const animRef = thumb === "min" ? minAnimRef : maxAnimRef

      animRef.current?.stop()

      if (shouldReduceMotion) {
        motionValue.jump(targetPercent)
        animRef.current = null
        return
      }

      animRef.current = animate(motionValue, targetPercent, {
        type: "spring",
        stiffness: 300,
        damping: 25,
        mass: 0.8,
        onComplete: () => {
          animRef.current = null
        },
      })
    },
    [minPercent, maxPercent, shouldReduceMotion]
  )

  const stopThumbAnimation = useCallback((thumb: Thumb) => {
    const animation = thumb === "min" ? minAnimRef : maxAnimRef
    animation.current?.stop()
    animation.current = null
  }, [])

  const computeRubberStretch = useCallback((clientX: number, sign: number) => {
    const rect = wrapperRectRef.current
    if (!rect) return 0

    const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right
    const overflow = Math.max(0, distancePast - DEAD_ZONE)

    return (
      sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1))
    )
  }, [])

  const handlePointerDown = useCallback((thumb: Thumb) => {
    return (e: React.PointerEvent) => {
      e.preventDefault()
      e.stopPropagation()
      ;(e.target as HTMLElement).setPointerCapture(e.pointerId)

      pointerDownPos.current = { x: e.clientX, y: e.clientY }
      isClickRef.current = true
      draggingThumb.current = thumb
      setIsDragging(true)
      setKeyboardFocusThumb(null)

      const wrapper = wrapperRef.current
      if (wrapper) {
        const rect = wrapper.getBoundingClientRect()
        wrapperRectRef.current = rect
        scaleRef.current = rect.width / wrapper.offsetWidth
      }
    }
  }, [])

  const handleTrackPointerDown = useCallback(
    (e: React.PointerEvent) => {
      const wrapper = wrapperRef.current
      if (wrapper) {
        const rect = wrapper.getBoundingClientRect()
        wrapperRectRef.current = rect
        scaleRef.current = rect.width / wrapper.offsetWidth
      }

      const value = positionToValue(e.clientX)
      const thumb: Thumb =
        Math.abs(value - min) <= Math.abs(value - max) ? "min" : "max"

      pointerDownPos.current = { x: e.clientX, y: e.clientY }
      isClickRef.current = true
      draggingThumb.current = thumb
      setIsDragging(true)
      setKeyboardFocusThumb(null)
      ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
    },
    [positionToValue, min, max]
  )

  const handlePointerMove = useCallback(
    (e: React.PointerEvent) => {
      const thumb = draggingThumb.current
      if (!thumb || !pointerDownPos.current) return

      const dx = e.clientX - pointerDownPos.current.x
      const dy = e.clientY - pointerDownPos.current.y
      if (isClickRef.current && Math.hypot(dx, dy) > CLICK_THRESHOLD) {
        isClickRef.current = false
      }
      if (isClickRef.current) return

      const rect = wrapperRectRef.current
      if (rect && !shouldReduceMotion) {
        // The min thumb only rubber-bands past the left edge (absoluteMin);
        // the max thumb only past the right edge (absoluteMax). Dragging
        // toward the other thumb is a hard clamp, not elastic.
        if (thumb === "min" && e.clientX < rect.left) {
          rubberStretch.jump(computeRubberStretch(e.clientX, -1))
        } else if (thumb === "max" && e.clientX > rect.right) {
          rubberStretch.jump(computeRubberStretch(e.clientX, 1))
        } else {
          rubberStretch.jump(0)
        }
      }

      const rawValue = roundValue(positionToValue(e.clientX), step)

      // Clamp one step short of the other thumb, not flush against it, so
      // min and max can never meet or cross. Applied before the motion value
      // jumps, so the thumb itself (not just the reported value) is bounded.
      const value =
        thumb === "min"
          ? clamp(rawValue, absoluteMin, max - step)
          : clamp(rawValue, min + step, absoluteMax)

      const motionValue = thumb === "min" ? minPercent : maxPercent
      stopThumbAnimation(thumb)
      motionValue.jump(percentFromValue(value))

      setRange(thumb === "min" ? [value, max] : [min, value])
    },
    [
      positionToValue,
      percentFromValue,
      setRange,
      step,
      min,
      max,
      absoluteMin,
      absoluteMax,
      minPercent,
      maxPercent,
      rubberStretch,
      computeRubberStretch,
      shouldReduceMotion,
      stopThumbAnimation,
    ]
  )

  const handlePointerUp = useCallback(
    (e: React.PointerEvent) => {
      const thumb = draggingThumb.current
      if (!thumb) return

      if (isClickRef.current) {
        const rawValue = positionToValue(e.clientX)
        const snapped =
          thumb === "min"
            ? clamp(roundValue(rawValue, step), absoluteMin, max - step)
            : clamp(roundValue(rawValue, step), min + step, absoluteMax)

        animateThumbTo(thumb, percentFromValue(snapped))
        setRange(([currentMin, currentMax]) =>
          thumb === "min" ? [snapped, currentMax] : [currentMin, snapped]
        )
      } else {
        // Snap the dragged value to the step grid on release.
        const current = thumb === "min" ? min : max
        const snapped = roundValue(current, step)
        if (snapped !== current) {
          animateThumbTo(thumb, percentFromValue(snapped))
          setRange(([currentMin, currentMax]) =>
            thumb === "min" ? [snapped, currentMax] : [currentMin, snapped]
          )
        }
      }

      if (!shouldReduceMotion && rubberStretch.get() !== 0) {
        animate(rubberStretch, 0, {
          type: "spring",
          visualDuration: 0.35,
          bounce: 0.15,
        })
      }

      draggingThumb.current = null
      pointerDownPos.current = null
      setIsDragging(false)
    },
    [
      positionToValue,
      percentFromValue,
      animateThumbTo,
      setRange,
      min,
      max,
      step,
      absoluteMin,
      absoluteMax,
      rubberStretch,
      shouldReduceMotion,
    ]
  )

  const discreteSteps = (absoluteMax - absoluteMin) / step
  const hashMarkCount = discreteSteps <= 10 ? discreteSteps - 1 : 9

  const hashMarkPct = (i: number) => {
    return discreteSteps <= 10
      ? (((i + 1) * step) / (absoluteMax - absoluteMin)) * 100
      : (i + 1) * 10
  }

  const handleKeyDown = useCallback(
    (thumb: Thumb) => (e: React.KeyboardEvent) => {
      const arrowStep = e.shiftKey ? step * 10 : step

      const current = thumb === "min" ? min : max
      const next = (() => {
        switch (e.key) {
          case "ArrowRight":
          case "ArrowUp":
            return current + arrowStep
          case "ArrowLeft":
          case "ArrowDown":
            return current - arrowStep
          case "Home":
            return thumb === "min" ? absoluteMin : min
          case "End":
            return thumb === "min" ? max : absoluteMax
          default:
            return null
        }
      })()

      if (next === null) return

      e.preventDefault()
      setKeyboardFocusThumb(thumb)

      const bounds: [number, number] =
        thumb === "min" ? [absoluteMin, max - step] : [min + step, absoluteMax]
      const snapped = clamp(roundValue(next, step), bounds[0], bounds[1])

      animateThumbTo(thumb, percentFromValue(snapped))
      setRange(([currentMin, currentMax]) =>
        thumb === "min" ? [snapped, currentMax] : [currentMin, snapped]
      )
    },
    [
      step,
      min,
      max,
      absoluteMin,
      absoluteMax,
      animateThumbTo,
      percentFromValue,
      setRange,
    ]
  )

  return (
    <div
      ref={wrapperRef}
      data-slot="elastic-range-slider"
      className={cn(
        "[--elastic-slider-height:--spacing(9)] [--elastic-slider-radius:var(--radius-lg)]",
        "[--elastic-slider-bg:var(--muted)]",
        "[--elastic-slider-fill:var(--muted-foreground)]/10",
        "[--elastic-slider-fill-active:var(--muted-foreground)]/20",
        "[--elastic-slider-hash:var(--muted-foreground)]/30",
        "[--elastic-slider-handle:var(--foreground)]",
        "[--elastic-slider-label:var(--muted-foreground)]",
        "[--elastic-slider-focus:var(--foreground)]",
        "relative h-(--elastic-slider-height)",
        className
      )}
    >
      <motion.div
        ref={trackRef}
        data-slot="elastic-slider-track"
        data-active={isActive}
        className="group/elastic-slider absolute inset-y-0 start-0 touch-none overflow-hidden rounded-(--elastic-slider-radius) bg-(--elastic-slider-bg) select-none"
        style={{ width: rubberWidth, x: rubberX }}
        onPointerDown={handleTrackPointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
      >
        <div
          data-slot="elastic-slider-hash-marks"
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
        >
          {Array.from({ length: hashMarkCount }, (_, i) => (
            <div
              key={i}
              className={cn(
                "absolute top-1/2 h-2 w-px -translate-y-1/2 rounded-full transition-colors duration-200",
                "bg-transparent group-data-[active=true]/elastic-slider:bg-(--elastic-slider-hash)"
              )}
              style={{
                left: `${hashMarkPct(i)}%`,
                transform: "translateX(-50%) translateY(-50%)",
              }}
            />
          ))}
        </div>

        <motion.div
          data-slot="elastic-slider-fill"
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 bg-(--elastic-slider-fill) transition-colors group-data-[active=true]/elastic-slider:bg-(--elastic-slider-fill-active)"
          style={{
            left: fillLeft,
            right: fillRight,
          }}
        />

        <motion.div
          role="slider"
          tabIndex={0}
          data-slot="elastic-slider-handle"
          data-focus-visible={keyboardFocusThumb === "min"}
          aria-label={minThumbLabel}
          aria-valuemin={absoluteMin}
          aria-valuemax={max - step}
          aria-valuenow={min}
          aria-valuetext={displayMin}
          onPointerDown={handlePointerDown("min")}
          onKeyDown={handleKeyDown("min")}
          onFocus={() => setKeyboardFocusThumb("min")}
          onBlur={() => setKeyboardFocusThumb(null)}
          className="absolute top-1/2 h-5 w-1 -translate-y-1/2 cursor-pointer touch-none rounded-full bg-(--elastic-slider-handle) opacity-0 transition-opacity outline-none group-data-[active=true]/elastic-slider:opacity-50 data-[focus-visible=true]:opacity-80 data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-ring/50"
          style={{ left: minLeft, y: "-50%" }}
        />
        <motion.div
          role="slider"
          tabIndex={0}
          data-slot="elastic-slider-handle"
          data-focus-visible={keyboardFocusThumb === "max"}
          aria-label={maxThumbLabel}
          aria-valuemin={min + step}
          aria-valuemax={absoluteMax}
          aria-valuenow={max}
          aria-valuetext={displayMax}
          onPointerDown={handlePointerDown("max")}
          onKeyDown={handleKeyDown("max")}
          onFocus={() => setKeyboardFocusThumb("max")}
          onBlur={() => setKeyboardFocusThumb(null)}
          className="absolute top-1/2 h-5 w-1 -translate-y-1/2 cursor-pointer touch-none rounded-full bg-(--elastic-slider-handle) opacity-0 transition-opacity outline-none group-data-[active=true]/elastic-slider:opacity-50 data-[focus-visible=true]:opacity-80 data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-ring/50"
          style={{ left: maxLeft, y: "-50%" }}
        />

        <span
          data-slot="elastic-slider-label"
          aria-hidden="true"
          className="pointer-events-none absolute start-3 top-1/2 inline-flex -translate-y-1/2 items-center text-sm/none font-medium text-(--elastic-slider-label) transition-colors"
        >
          {label}
        </span>

        <span
          data-slot="elastic-slider-value"
          aria-hidden="true"
          className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 font-mono text-sm/none font-medium text-(--elastic-slider-label) transition-colors group-data-[active=true]/elastic-slider:text-(--elastic-slider-focus)"
        >
          {displayMin} – {displayMax}
        </span>
      </motion.div>
    </div>
  )
}
registry/base/hooks/use-controllable-state.ts
// Thanks @radix-ui

import * as React from "react"

// use-layout-effect.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx

/**
 * On the server, React emits a warning when calling `useLayoutEffect`.
 * This is because neither `useLayoutEffect` nor `useEffect` run on the server.
 * We use this safe version which suppresses the warning by replacing it with a noop on the server.
 *
 * See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
 */
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {}

// use-controllable-state.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx

// Prevent bundlers from trying to optimize the import
const useInsertionEffect: typeof useLayoutEffect =
  (React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect

type ChangeHandler<T> = (state: T) => void
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>

interface UseControllableStateParams<T> {
  prop?: T | undefined
  defaultProp: T
  onChange?: ChangeHandler<T>
  caller?: string
}

export function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
  caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
  const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
    useUncontrolledState({
      defaultProp,
      onChange,
    })
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolledProp

  // OK to disable conditionally calling hooks here because they will always run
  // consistently in the same environment. Bundlers should be able to remove the
  // code block entirely in production.
  /* eslint-disable react-hooks/rules-of-hooks */
  if (process.env.NODE_ENV !== "production") {
    const isControlledRef = React.useRef(prop !== undefined)
    React.useEffect(() => {
      const wasControlled = isControlledRef.current
      if (wasControlled !== isControlled) {
        const from = wasControlled ? "controlled" : "uncontrolled"
        const to = isControlled ? "controlled" : "uncontrolled"
        console.warn(
          `${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
        )
      }
      isControlledRef.current = isControlled
    }, [isControlled, caller])
  }
  /* eslint-enable react-hooks/rules-of-hooks */

  const setValue = React.useCallback<SetStateFn<T>>(
    (nextValue) => {
      if (isControlled) {
        const value = isFunction(nextValue) ? nextValue(prop) : nextValue
        if (value !== prop) {
          onChangeRef.current?.(value)
        }
      } else {
        setUncontrolledProp(nextValue)
      }
    },
    [isControlled, prop, setUncontrolledProp, onChangeRef]
  )

  return [value, setValue]
}

function useUncontrolledState<T>({
  defaultProp,
  onChange,
}: Omit<UseControllableStateParams<T>, "prop">): [
  Value: T,
  setValue: React.Dispatch<React.SetStateAction<T>>,
  OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
] {
  const [value, setValue] = React.useState(defaultProp)
  const prevValueRef = React.useRef(value)

  const onChangeRef = React.useRef(onChange)
  useInsertionEffect(() => {
    onChangeRef.current = onChange
  }, [onChange])

  React.useEffect(() => {
    if (prevValueRef.current !== value) {
      onChangeRef.current?.(value)
      prevValueRef.current = value
    }
  }, [value, prevValueRef])

  return [value, setValue, onChangeRef]
}

function isFunction(value: unknown): value is (...args: never[]) => unknown {
  return typeof value === "function"
}

More from persianlabsui

Browse all components in this library →