Skip to content

Range Slider

Tick dots and a thumb that bounces as it snaps to steps.

Installation

CLI

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

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/range-slider/range-slider.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent } from "react";
    import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring } from "@/lib/motion";
    
    export interface RangeSliderProps
      extends Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Controlled value: one number, or two for a range. */
      value?: number[];
      /** Initial value when uncontrolled. @default [min] */
      defaultValue?: number[];
      /** Called on every change while dragging or using the keyboard. */
      onValueChange?: (value: number[]) => void;
      /** Called once a change is finished: pointer released or key pressed. */
      onValueCommit?: (value: number[]) => void;
      /** Lowest value. @default 0 */
      min?: number;
      /** Highest value. @default 100 */
      max?: number;
      /** Distance between values; the thumb snaps to each step. @default 1 */
      step?: number;
      /** Show a dot at every step. @default true when there are 20 steps or fewer */
      ticks?: boolean;
      /** Formats values for the tooltip and for screen readers. */
      formatValue?: (value: number) => string;
      /** When the value tooltip shows. @default "active" */
      tooltip?: "active" | "always" | "never";
      /** Accessible names for the thumbs, e.g. ["Minimum price", "Maximum price"]. */
      thumbLabels?: string[];
      /** Disables pointer and keyboard input. @default false */
      disabled?: boolean;
    }
    
    // A lightly damped spring, so the thumb overshoots a touch every time it snaps to a step.
    const SNAP = { stiffness: 520, damping: 19, mass: 0.7 };
    
    const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);
    const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
    
    export function RangeSlider({
      value,
      defaultValue,
      onValueChange,
      onValueCommit,
      min = 0,
      max = 100,
      step = 1,
      ticks,
      formatValue = (v) => String(v),
      tooltip = "active",
      thumbLabels,
      disabled = false,
      className,
      "aria-label": ariaLabel,
      ...props
    }: RangeSliderProps) {
      const [internal, setInternal] = useState(() => defaultValue ?? [min]);
      const values = (value ?? internal).map((v) => clamp(v, min, max));
      const range = values.length > 1;
      const [dragging, setDragging] = useState<number | null>(null);
      const [focused, setFocused] = useState<number | null>(null);
      const trackRef = useRef<HTMLDivElement>(null);
      const thumbRefs = useRef<(HTMLDivElement | null)[]>([]);
      const reduceMotion = useReducedMotion();
    
      const percent = (v: number) => ((v - min) / (max - min || 1)) * 100;
      const v0 = values[0];
      const v1 = values[1] ?? values[0];
      const p0 = useSpring(percent(v0), SNAP);
      const p1 = useSpring(percent(v1), SNAP);
      const x0 = useTransform(p0, (v) => `${v}%`);
      const x1 = useTransform(p1, (v) => `${v}%`);
      // The fill is clipped rather than scaled, so its rounded ends never stretch.
      const fillClip = useTransform(() => {
        const a = p0.get();
        const b = range ? p1.get() : a;
        const lo = range ? Math.min(a, b) : 0;
        const hi = range ? Math.max(a, b) : a;
        return `inset(0 ${100 - hi}% 0 ${lo}% round 9999px)`;
      });
    
      useEffect(() => {
        const t0 = ((v0 - min) / (max - min || 1)) * 100;
        const t1 = ((v1 - min) / (max - min || 1)) * 100;
        if (reduceMotion) {
          p0.jump(t0);
          p1.jump(t1);
        } else {
          p0.set(t0);
          p1.set(t1);
        }
      }, [v0, v1, min, max, reduceMotion, p0, p1]);
    
      const snap = (raw: number) => {
        const stepped = Math.round((raw - min) / step) * step + min;
        return clamp(Number(stepped.toFixed(decimals(step))), min, max);
      };
    
      function update(index: number, raw: number) {
        let next = snap(raw);
        // Range thumbs can meet but never cross.
        if (range) next = index === 0 ? Math.min(next, values[1]) : Math.max(next, values[0]);
        if (next === values[index]) return values;
        const updated = values.map((v, i) => (i === index ? next : v));
        if (value === undefined) setInternal(updated);
        onValueChange?.(updated);
        return updated;
      }
    
      function valueAt(clientX: number) {
        const rect = trackRef.current!.getBoundingClientRect();
        return min + clamp((clientX - rect.left) / rect.width, 0, 1) * (max - min);
      }
    
      function onPointerDown(event: PointerEvent<HTMLDivElement>) {
        if (disabled || event.button !== 0) return;
        event.preventDefault();
        const target = valueAt(event.clientX);
        let index = 0;
        if (range) {
          if (target <= values[0]) index = 0;
          else if (target >= values[1]) index = 1;
          else index = target - values[0] <= values[1] - target ? 0 : 1;
        }
        event.currentTarget.setPointerCapture(event.pointerId);
        thumbRefs.current[index]?.focus({ preventScroll: true });
        setDragging(index);
        update(index, target);
      }
    
      function onPointerMove(event: PointerEvent<HTMLDivElement>) {
        if (dragging !== null) update(dragging, valueAt(event.clientX));
      }
    
      function endDrag() {
        if (dragging === null) return;
        setDragging(null);
        onValueCommit?.(values);
      }
    
      function onThumbKey(index: number, event: KeyboardEvent<HTMLDivElement>) {
        const big = Math.max(step, snap(min + (max - min) / 10) - min);
        const current = values[index];
        const targets: Record<string, number> = {
          ArrowRight: current + step,
          ArrowUp: current + step,
          ArrowLeft: current - step,
          ArrowDown: current - step,
          PageUp: current + big,
          PageDown: current - big,
          Home: min,
          End: max,
        };
        if (!(event.key in targets) || disabled) return;
        event.preventDefault();
        // Not `onValueCommit?.(update(...))`: optional calls skip evaluating their arguments.
        const next = update(index, targets[event.key]);
        onValueCommit?.(next);
      }
    
      const stepCount = Math.round((max - min) / step);
      const showTicks = ticks ?? stepCount <= 20;
      const lo = range ? values[0] : min;
      const hi = range ? values[1] : values[0];
    
      return (
        <div
          role={range ? "group" : undefined}
          aria-label={range ? ariaLabel : undefined}
          className={cn("relative w-full touch-none select-none", disabled && "pointer-events-none opacity-50", className)}
          {...props}
        >
          <div
            ref={trackRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            className="relative flex h-10 cursor-pointer items-center"
          >
            <div className="relative h-1.5 w-full rounded-full bg-muted">
              <motion.div className="absolute inset-0 rounded-full bg-primary" style={{ clipPath: fillClip }} />
              {showTicks
                ? Array.from({ length: stepCount + 1 }, (_, i) => {
                    const tickValue = min + i * step;
                    const inFill = tickValue >= lo && tickValue <= hi;
                    return (
                      <span
                        key={i}
                        aria-hidden="true"
                        style={{ left: `${percent(tickValue)}%` }}
                        className={cn(
                          "absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-150",
                          inFill ? "bg-primary-foreground/80" : "bg-foreground/20",
                        )}
                      />
                    );
                  })
                : null}
            </div>
    
            {values.slice(0, 2).map((thumbValue, index) => {
              const showTooltip = tooltip === "always" || (tooltip === "active" && (dragging === index || focused === index));
              const label = thumbLabels?.[index] ?? (range ? (index === 0 ? "Minimum" : "Maximum") : ariaLabel);
              return (
                // Full-width layer translated by a percentage: the thumb sits on its left edge.
                <motion.div
                  key={index}
                  className="pointer-events-none absolute inset-y-0 left-0 w-full"
                  style={{ x: index === 0 ? x0 : x1 }}
                >
                  <motion.div
                    ref={(node) => {
                      thumbRefs.current[index] = node;
                    }}
                    role="slider"
                    tabIndex={disabled ? -1 : 0}
                    aria-label={label}
                    aria-valuemin={range && index === 1 ? values[0] : min}
                    aria-valuemax={range && index === 0 ? values[1] : max}
                    aria-valuenow={thumbValue}
                    aria-valuetext={formatValue(thumbValue)}
                    aria-orientation="horizontal"
                    aria-disabled={disabled || undefined}
                    onKeyDown={(event) => onThumbKey(index, event)}
                    onFocus={() => setFocused(index)}
                    onBlur={() => setFocused((f) => (f === index ? null : f))}
                    animate={{ scale: dragging === index && !reduceMotion ? 1.15 : 1 }}
                    transition={spring.snappy}
                    className={cn(
                      "pointer-events-auto absolute top-1/2 left-0 size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border border-border bg-background outline-none",
                      "shadow-[0_1px_2px_rgb(0_0_0/0.12),0_2px_8px_-2px_rgb(0_0_0/0.18)] focus-visible:ring-4 focus-visible:ring-ring/30",
                      dragging === index && "cursor-grabbing",
                      dragging === index || focused === index ? "z-20" : "z-10",
                    )}
                  >
                    <AnimatePresence>
                      {showTooltip ? (
                        <motion.span
                          aria-hidden="true"
                          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.9 }}
                          animate={{ opacity: 1, y: 0, scale: 1 }}
                          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.9 }}
                          transition={{ duration: duration.quick, ease: ease.out }}
                          className="absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-md"
                        >
                          {formatValue(thumbValue)}
                        </motion.span>
                      ) : null}
                    </AnimatePresence>
                  </motion.div>
                </motion.div>
              );
            })}
          </div>
        </div>
      );
    }

Usage

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

export function PriceFilter() {
  const [range, setRange] = useState([200, 750]);
  return (
    <RangeSlider
      aria-label="Price range"
      thumbLabels={["Minimum price", "Maximum price"]}
      value={range}
      onValueChange={setRange}
      max={1000}
      step={50}
    />
  );
}

API reference

PropTypeDefaultDescription
valuenumber[]–Controlled value: one number, or two for a range.
defaultValuenumber[][min]Initial value when uncontrolled.
onValueChange(value: number[]) => void–Called on every change while dragging or using the keyboard.
onValueCommit(value: number[]) => void–Called once a change is finished: pointer released or key pressed.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Distance between values; the thumb snaps to each step.
ticksbooleantrue when there are 20 steps or fewerShow a dot at every step.
formatValue(value: number) => string–Formats values for the tooltip and for screen readers.
tooltip"active" | "always" | "never""active"When the value tooltip shows.
thumbLabelsstring[]–Accessible names for the thumbs, e.g. ["Minimum price", "Maximum price"].
disabledbooleanfalseDisables pointer and keyboard input.

Also accepts every native <div> attribute, including ref and className.

  • Pass one value for a single thumb or two for a range.
  • The thumb rides a full-width layer translated by a percentage, and the fill is a clip-path, so dragging never animates layout.
  • Ticks appear automatically for 20 steps or fewer. Set ticks to force them on or off.

Accessibility

  • Each thumb has role="slider" with aria-valuenow, aria-valuemin, aria-valuemax and an aria-valuetext built from formatValue.
  • Arrow keys move one step, PageUp and PageDown move about a tenth of the range, and Home and End jump to the bounds.
  • In range mode the thumbs are labelled ("Minimum", "Maximum" or thumbLabels) and each one's bounds follow the other, so they never cross.
  • Clicking the track moves the nearest thumb and focuses it, so the keyboard picks up where the pointer left off.
  • With reduced motion enabled, the thumb jumps to each step instead of springing.