UIForms and Inputsmotion/react

RangeSlider

A single or dual-thumb slider with a gradient-filled track, thumbs that glide to keyboard steps and swell while dragged, and a value tooltip that springs up above the active thumb.

Preview

Price range

Customize

11 props
0
1000
10

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add range-slider

Writes components/ui/range-slider.tsx and components/styles/range-slider.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { RangeSlider } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <RangeSlider />6  );7}

Props

PropTypeDefault
value

Controlled values: one number for a single thumb, two for a range.

number[]undefined
defaultValue

Initial values when uncontrolled.

number[][20%, 80%] of the range, or [50%]
onValueChange

Called continuously while values change.

(value: number[]) => voidundefined
onValueCommit

Called when a drag ends or after a key step.

(value: number[]) => voidundefined
range

Render two thumbs (a range) instead of one.

booleantrue
min

Minimum value.

number0
max

Maximum value.

number1000
step

Step increment; values snap to it.

number10
label

Visible label and accessible name.

string'Price range'
prefix

Text before each formatted value.

string'$'
suffix

Text after each formatted value, e.g. '%'.

string''
tooltip

Show the value tooltip always, only on hover/focus/drag, or never.

'always' | 'active' | 'never''active'
size

Rail and thumb size preset.

'sm' | 'md' | 'lg''md'
accentColor

Fill, thumb ring and tooltip color.

string'#6366f1'
disabled

Disable the slider.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Implements the WAI-ARIA slider (and multi-thumb slider) pattern: each thumb is a focusable role="slider" with aria-valuenow, aria-valuetext (formatted with prefix/suffix) and aria-valuemin/max that follow the other thumb in range mode, so thumbs cannot cross. Single sliders are labelled by the visible label; range thumbs are named 'Minimum <label>' / 'Maximum <label>'. Arrow keys step, PageUp/PageDown take 10% steps, Home/End jump to the bounds. Clicking the track moves the nearest thumb and focuses it. The tooltip and readout are aria-hidden duplicates. Under prefers-reduced-motion thumbs and fill move without easing and the tooltip only fades.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/range-slider.tsx
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/range-slider.module.css";6 7export interface RangeSliderProps {8  /** Controlled values: one number for a single thumb, two for a range */9  value?: number[];10  /** Initial values when uncontrolled (defaults to [20, 80] for a range, [50] otherwise) */11  defaultValue?: number[];12  /** Called continuously while values change */13  onValueChange?: (value: number[]) => void;14  /** Called when a drag or key interaction ends */15  onValueCommit?: (value: number[]) => void;16  /** Two thumbs (range) instead of one */17  range?: boolean;18  /** Minimum value */19  min?: number;20  /** Maximum value */21  max?: number;22  /** Step increment */23  step?: number;24  /** Visible label (also the accessible name) */25  label?: string;26  /** Text before each value, e.g. "$" */27  prefix?: string;28  /** Text after each value, e.g. "%" */29  suffix?: string;30  /** When the value tooltip is shown */31  tooltip?: "always" | "active" | "never";32  /** Size preset */33  size?: "sm" | "md" | "lg";34  /** Fill, thumb ring and tooltip color */35  accentColor?: string;36  /** Disable the slider */37  disabled?: boolean;38  /** Optional className merged onto the root */39  className?: string;40  /** Optional inline styles merged onto the root */41  style?: React.CSSProperties;42}43 44export function RangeSlider({45  value: valueProp,46  defaultValue,47  onValueChange,48  onValueCommit,49  range = true,50  min = 0,51  max = 1000,52  step = 10,53  label = "Price range",54  prefix = "$",55  suffix = "",56  tooltip = "active",57  size = "md",58  accentColor = "#6366f1",59  disabled = false,60  className = "",61  style,62}: RangeSliderProps) {63  const reduce = useReducedMotion();64  const lo = Math.min(min, max);65  const hi = Math.max(min, max) === lo ? lo + 1 : Math.max(min, max);66  const inc = step > 0 ? step : 1;67  const count = range ? 2 : 1;68  const snap = React.useCallback((n: number) => Math.min(hi, Math.max(lo, lo + Math.round((n - lo) / inc) * inc)), [hi, lo, inc]);69  const normalize = React.useCallback(70    (vals: number[] | undefined) => {71      const base = vals && vals.length ? vals : range ? [lo + (hi - lo) * 0.2, lo + (hi - lo) * 0.8] : [lo + (hi - lo) / 2];72      const out = (count === 2 ? [base[0], base[1] ?? base[0]] : [base[0]]).map(snap);73      return out.sort((a, b) => a - b);74    },75    [count, hi, lo, range, snap],76  );77  const [inner, setInner] = React.useState(() => normalize(defaultValue));78  const values = normalize(valueProp ?? inner);79  const valuesRef = React.useRef(values);80  valuesRef.current = values;81  const [active, setActive] = React.useState<number | null>(null);82  const [dragging, setDragging] = React.useState(false);83  const trackRef = React.useRef<HTMLDivElement>(null);84  const thumbRefs = React.useRef<Array<HTMLDivElement | null>>([]);85  const labelId = React.useId();86 87  const pct = (v: number) => ((v - lo) / (hi - lo)) * 100;88  const fmt = (v: number) => `${prefix}${Number(v.toFixed(6)).toLocaleString("en-US")}${suffix}`;89 90  const setThumb = (index: number, raw: number) => {91    const next = [...valuesRef.current];92    const lower = index === 1 ? next[0] : lo;93    const upper = index === 0 && count === 2 ? next[1] : hi;94    const v = Math.min(upper, Math.max(lower, snap(raw)));95    if (v === next[index]) return;96    next[index] = v;97    valuesRef.current = next;98    if (valueProp === undefined) setInner(next);99    onValueChange?.(next);100  };101 102  const valueAt = (clientX: number) => {103    const rect = trackRef.current?.getBoundingClientRect();104    if (!rect || rect.width === 0) return lo;105    return lo + ((clientX - rect.left) / rect.width) * (hi - lo);106  };107 108  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {109    if (disabled || e.button !== 0) return;110    e.preventDefault();111    const raw = valueAt(e.clientX);112    const cur = valuesRef.current;113    let index = 0;114    if (count === 2) {115      const d0 = Math.abs(raw - cur[0]);116      const d1 = Math.abs(raw - cur[1]);117      index = d0 === d1 ? (raw > cur[1] ? 1 : 0) : d1 < d0 ? 1 : 0;118    }119    e.currentTarget.setPointerCapture(e.pointerId);120    setActive(index);121    setDragging(true);122    thumbRefs.current[index]?.focus();123    setThumb(index, raw);124  };125 126  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {127    if (!dragging || active === null) return;128    setThumb(active, valueAt(e.clientX));129  };130 131  const endDrag = () => {132    if (!dragging) return;133    setDragging(false);134    onValueCommit?.(valuesRef.current);135  };136 137  const onKeyDown = (index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {138    if (disabled) return;139    const v = valuesRef.current[index];140    const big = Math.max(inc, Math.round((hi - lo) / 10 / inc) * inc);141    const map: Record<string, number | undefined> = {142      ArrowRight: v + inc,143      ArrowUp: v + inc,144      ArrowLeft: v - inc,145      ArrowDown: v - inc,146      PageUp: v + big,147      PageDown: v - big,148      Home: lo,149      End: hi,150    };151    const next = map[e.key];152    if (next === undefined) return;153    e.preventDefault();154    setThumb(index, next);155    onValueCommit?.(valuesRef.current);156  };157 158  const start = count === 2 ? pct(values[0]) : 0;159  const end = pct(values[count - 1]);160  const pop = reduce ? { duration: 0.08 } : { type: "spring" as const, stiffness: 520, damping: 30 };161  const names = count === 2 ? ["Minimum", "Maximum"] : [""];162 163  return (164    <div165      className={`${styles.root} ${className}`.trim()}166      data-size={size}167      data-dragging={dragging ? "" : undefined}168      data-disabled={disabled ? "" : undefined}169      style={{ "--mf-rs-accent": accentColor, ...style } as React.CSSProperties}170    >171      <div className={styles.header}>172        <span id={labelId} className={styles.label}>173          {label}174        </span>175        <span className={styles.readout} aria-hidden="true">176          {values.map(fmt).join(" – ")}177        </span>178      </div>179      <div ref={trackRef} className={styles.track} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>180        <div className={styles.rail} aria-hidden="true">181          <div className={styles.fill} style={{ left: `${start}%`, width: `${end - start}%` }} />182        </div>183        {values.map((v, i) => {184          const show = tooltip === "always" || (tooltip === "active" && active === i);185          return (186            <div187              key={i}188              ref={(el) => {189                thumbRefs.current[i] = el;190              }}191              role="slider"192              tabIndex={disabled ? -1 : 0}193              aria-labelledby={names[i] ? undefined : labelId}194              aria-label={names[i] ? `${names[i]} ${label.toLowerCase()}` : undefined}195              aria-valuemin={i === 1 ? values[0] : lo}196              aria-valuemax={i === 0 && count === 2 ? values[1] : hi}197              aria-valuenow={v}198              aria-valuetext={fmt(v)}199              aria-orientation="horizontal"200              aria-disabled={disabled || undefined}201              className={styles.thumb}202              data-active={active === i ? "" : undefined}203              style={{ left: `${pct(v)}%`, zIndex: active === i ? 3 : 2 }}204              onKeyDown={onKeyDown(i)}205              onFocus={() => setActive(i)}206              onBlur={() => !dragging && setActive(null)}207              onPointerEnter={() => !dragging && tooltip === "active" && setActive(i)}208              onPointerLeave={() => !dragging && document.activeElement !== thumbRefs.current[i] && setActive(null)}209            >210              <AnimatePresence>211                {show && (212                  <motion.span213                    aria-hidden="true"214                    className={styles.tip}215                    initial={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.8, x: "-50%" }}216                    animate={{ opacity: 1, y: 0, scale: 1, x: "-50%" }}217                    exit={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.85, x: "-50%" }}218                    transition={pop}219                  >220                    {fmt(v)}221                  </motion.span>222                )}223              </AnimatePresence>224            </div>225          );226        })}227      </div>228      <div className={styles.scale} aria-hidden="true">229        <span>{fmt(lo)}</span>230        <span>{fmt(hi)}</span>231      </div>232    </div>233  );234}