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
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add range-sliderWrites 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.
1import { RangeSlider } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <RangeSlider />6 );7}Props
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
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}