Last active
September 9, 2025 02:12
-
-
Save Bandit/7b879582dca84bc8ec891f4c5f023c9a to your computer and use it in GitHub Desktop.
A UX-first React range slider implementation. Working demo: https://codesandbox.io/p/sandbox/sharp-cray-zm62vd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| So there's a critical UX flaw with most range slider implementations, whereby if | |
| both handles are on top of each other (e.g. to select a single value range like 80-80) | |
| then it's not clear to the user which handle is on top, and so dragging in a certain | |
| direction doesn't work. The user is then either bewildered or forced to drag in the | |
| opposite direction to the one they want in order to reveal the other handle. | |
| This component sets out to solve that problem by allowing the handles to pass each | |
| other, without sacrificing keyboard support (and minimising the mangling of A11y). | |
| Still needed: | |
| - Add mobile styles/behaviour | |
| - Performance pass | |
| - Non-linear scaling: https://www.howdoi.me/blog/slider-scale.html | |
| - Optional notches on the gutter to snap to common values | |
| - Better keyboard/A11y handling: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Keyboard-navigable_JavaScript_widgets | |
| - Refactor to use pointerEvents? | |
| */ | |
| import { useState, useRef, useCallback, useEffect } from "react"; | |
| import { z } from "zod"; // optional - can also use vanilla JS or not validate props at all | |
| // this is used in errors and CSS scoping | |
| const componentName = "BetterReactRangeSlider"; | |
| // Helper function to clamp a value between a minimum and maximum | |
| const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); | |
| const clampPercentage = (p) => clamp(p, 0, 100); | |
| /** | |
| * Bandit's Better React Range Slider | |
| * https://gist.github.com/Bandit/7b879582dca84bc8ec891f4c5f023c9a | |
| * | |
| * @param {number} [increment=1] - The step size by which the slider value changes. | |
| * @param {array} [range=[0, 100]] - The range of the slider (array with at least min and max values). If more than 2 numbers passed, it becomes the available values. | |
| * @param {array} [value=[0, 100]] - The current value of the slider (array with min and max values). | |
| * @param {string} [tooltip="active"] - The tooltip display mode ("hover", "active", "always", "never"). | |
| * @param {string|array} [inputName="range[]"] - The name attribute of the input fields (can also pass an array for min/max). | |
| * @param {function} [onChange] - A function to call each time the value changes. Single argument containing [min, max] values. | |
| * | |
| * @returns {JSX.Element} The rendered slider component. | |
| */ | |
| export default function BetterReactRangeSlider({ | |
| increment = 1, | |
| range = [0, 100], | |
| value = [0, 100], | |
| tooltip = "active", | |
| inputName = "range[]", | |
| onChange, | |
| }) { | |
| // Validate the props | |
| validateVanilla({ range, increment, value, tooltip, inputName }); | |
| validateZod({ range, increment, value, tooltip, inputName }); | |
| // State to hold the available values of the whole slider | |
| const [sliderRange, setSliderRange] = useState({ | |
| min: range[0], | |
| max: range.at(-1), | |
| all: range, | |
| }); | |
| // helpers | |
| const mm = useCallback((v) => v - sliderRange.min, [sliderRange.min]); | |
| const toNearest = useCallback( | |
| // TODO: if more than 2 values are passed in the range, treat as a set of available values | |
| (v) => Math.round(v / increment) * increment, | |
| [increment] | |
| ); | |
| const toPercentage = useCallback( | |
| (v) => (mm(v) / mm(sliderRange.max)) * 100, | |
| [sliderRange.max, mm] | |
| ); | |
| const toValue = useCallback( | |
| (p) => toNearest((p / 100) * mm(sliderRange.max) + sliderRange.min), | |
| [sliderRange.min, sliderRange.max, mm] | |
| ); | |
| // State to hold the current min/max values of the selected range | |
| const startValues = | |
| Array.isArray(value) && value.length == 2 | |
| ? value | |
| : [sliderRange.min, sliderRange.max]; | |
| const [values, setValues] = useState({ | |
| min: Math.min(...startValues), | |
| max: Math.max(...startValues), | |
| }); | |
| // State to hold the handle values of the slider (as percentages) | |
| const [handleValues, setHandleValues] = useState({ | |
| a: toPercentage(values.min), | |
| b: toPercentage(values.max), | |
| }); | |
| // State to track which handle is currently active | |
| const [activeHandle, setActiveHandle] = useState(null); | |
| // State to track whether we're currently dragging | |
| const [dragging, setDragging] = useState(false); | |
| // State to track the handle order | |
| const [handleOrder, setHandleOrder] = useState(["a", "b"]); | |
| // Ref for the slider track element to get its dimensions | |
| const trackRef = useRef(null); | |
| // set min and max value based on handle value | |
| const setMinMax = useCallback(() => { | |
| // Convert percentage to value | |
| const a = toValue(handleValues.a); | |
| const b = toValue(handleValues.b); | |
| // Calculate current min and max value | |
| const newObj = { | |
| min: Math.min(a, b), | |
| max: Math.max(a, b), | |
| }; | |
| setValues(newObj); | |
| // if there's an onChange handler, fire it with the values | |
| if (typeof onChange === "function") onChange.call(newObj); | |
| }, [handleValues, toValue, onChange]); | |
| // A ref to store information about the current drag operation | |
| // This is used to avoid stale state within the event listeners | |
| const dragState = useRef({ | |
| initialX: 0, | |
| a: 0, | |
| b: 0, | |
| trackWidth: 0, | |
| }).current; | |
| // This function is called when a drag starts (on mouse down or touch start) | |
| const handleDragStart = useCallback( | |
| (e, handle) => { | |
| e.preventDefault(); | |
| if (document.activeElement !== e.target) e.target.focus(); // this sets the activeHandle | |
| setDragging(true); | |
| // Store initial positions and dimensions | |
| const clientX = | |
| e.type === "touchstart" ? e.touches[0].clientX : e.clientX; | |
| dragState.initialX = clientX; | |
| dragState.a = handleValues.a; | |
| dragState.b = handleValues.b; | |
| if (trackRef.current) { | |
| dragState.trackWidth = trackRef.current.offsetWidth; | |
| } | |
| }, | |
| [handleValues, dragState] | |
| ); | |
| // This function is called when the mouse or touch moves | |
| const handleDragMove = useCallback( | |
| (e) => { | |
| if (activeHandle === null || !dragState.trackWidth) return; | |
| if (e.type === "mousemove" && !dragging) return; | |
| const clientX = e.type === "touchmove" ? e.touches[0].clientX : e.clientX; | |
| const deltaX = clientX - dragState.initialX; | |
| const deltaPercent = (deltaX / dragState.trackWidth) * 100; | |
| // find the nearest percentage that matches a correct increment value | |
| const newPosition = toPercentage( | |
| toValue(dragState[activeHandle] + deltaPercent) | |
| ); | |
| setHandleValues((obj) => ({ | |
| ...obj, | |
| [activeHandle]: clampPercentage(newPosition), | |
| })); | |
| }, | |
| [activeHandle, dragState, dragging] | |
| ); | |
| // This function is called when the drag ends (on mouse up or touch end) | |
| const handleDragEnd = useCallback(() => { | |
| setDragging(false); | |
| }, []); | |
| // Handles clicks on the slider track to move the selected or nearest handle | |
| const handleTrackClick = useCallback( | |
| (e) => { | |
| if (!trackRef.current) return; | |
| // ignore clicks that start on a handle | |
| if (e.target !== e.currentTarget) return; | |
| const rect = trackRef.current.getBoundingClientRect(); | |
| const clientX = | |
| e.type === "touchstart" ? e.touches[0].clientX : e.clientX; | |
| const clickX = clientX - rect.left; | |
| const clickPercent = clampPercentage((clickX / rect.width) * 100); | |
| // find the nearest percentage that matches a correct increment value | |
| const newPosition = toPercentage(toValue(clickPercent)); | |
| // move the currently selected handle (doesn't work with onClick due to event order) | |
| // otherwise move the handle closest to the click/tap | |
| let handleToMove = activeHandle; | |
| if (!handleToMove) { | |
| const distToA = Math.abs(newPosition - handleValues.a); | |
| const distToB = Math.abs(newPosition - handleValues.b); | |
| handleToMove = distToA < distToB ? "a" : "b"; | |
| } | |
| setHandleValues((obj) => ({ ...obj, [handleToMove]: clickPercent })); | |
| }, | |
| [activeHandle, handleValues] | |
| ); | |
| // Listen for keyboard left/right arrow presses | |
| const handleKeypress = useCallback( | |
| (e) => { | |
| if (activeHandle) { | |
| // if the shift key was held, double the increment | |
| const inc = e.shiftKey ? increment * 2 : increment; | |
| // calculate the percentage that increment corresponds to | |
| const percentageToMove = (inc / mm(sliderRange.max)) * 100; | |
| const decreaseKeys = ["ArrowLeft", "ArrowDown", "PageDown"]; | |
| const increaseKeys = ["ArrowRight", "ArrowUp", "PageUp"]; | |
| if (decreaseKeys.includes(e.key) || increaseKeys.includes(e.key)) { | |
| // determine the direction: 1 for increase, -1 for decrease | |
| const direction = increaseKeys.includes(e.key) ? 1 : -1; | |
| setHandleValues((obj) => ({ | |
| ...obj, | |
| [activeHandle]: clampPercentage( | |
| obj[activeHandle] + direction * percentageToMove | |
| ), | |
| })); | |
| } | |
| } | |
| }, | |
| [activeHandle, sliderRange, mm] | |
| ); | |
| // Handle page blur | |
| const handlePageBlur = useCallback(() => { | |
| setActiveHandle(null); | |
| setDragging(false); | |
| document.activeElement?.blur(); | |
| }); | |
| // update handle order when necessary | |
| useEffect(() => { | |
| if (handleValues[handleOrder[0]] > handleValues[handleOrder[1]]) | |
| setHandleOrder(handleOrder.toReversed()); | |
| }, [handleValues]); | |
| // Update min/max whenever the handles move | |
| useEffect(() => setMinMax(), [handleValues]); | |
| // Effect to add and remove global event listeners for move and end events | |
| useEffect(() => { | |
| if (activeHandle !== null) { | |
| window.addEventListener("mousemove", handleDragMove); | |
| window.addEventListener("touchmove", handleDragMove); | |
| window.addEventListener("keydown", handleKeypress); | |
| } | |
| // Cleanup function to remove listeners | |
| return () => { | |
| window.removeEventListener("mousemove", handleDragMove); | |
| window.removeEventListener("touchmove", handleDragMove); | |
| window.removeEventListener("keydown", handleKeypress); | |
| }; | |
| }, [activeHandle, handleDragMove, handleKeypress]); | |
| useEffect(() => { | |
| window.addEventListener("mouseup", handleDragEnd); | |
| window.addEventListener("touchend", handleDragEnd); | |
| // Cleanup function to remove listeners | |
| return () => { | |
| window.removeEventListener("mouseup", handleDragEnd); | |
| window.removeEventListener("touchend", handleDragEnd); | |
| }; | |
| }, [handleDragEnd]); | |
| useEffect(() => { | |
| // Handle the edge case where someone releases the mouse while outside the document | |
| window.addEventListener("blur", handlePageBlur); | |
| // Cleanup function to remove listeners | |
| return () => { | |
| window.removeEventListener("blur", handlePageBlur); | |
| }; | |
| }, []); | |
| // --- Range Calculation for Styling --- | |
| // Determine the left offset and width for the highlighted range bar | |
| const rangeStart = toPercentage(values.min); | |
| const rangeEnd = toPercentage(values.max); | |
| const rangeWidth = rangeEnd - rangeStart; | |
| // This re-renders whenever the order of the handles changes | |
| const handlesHTML = handleOrder.map((handle, i) => ( | |
| <div | |
| key={handle} | |
| data-brrs-handle | |
| data-brrs-active={activeHandle === handle} | |
| onMouseDown={(e) => handleDragStart(e, handle)} | |
| onTouchStart={(e) => handleDragStart(e, handle)} | |
| onFocus={() => setActiveHandle(handle)} | |
| onBlur={() => setActiveHandle(null)} | |
| style={{ left: `${handleValues[handle]}%` }} | |
| className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-white border-2 border-blue-500 rounded-full shadow-md focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 ${ | |
| dragging ? "cursor-grabbing" : "cursor-grab" | |
| }`} | |
| role="slider" | |
| aria-valuemin={sliderRange.min} | |
| aria-valuemax={sliderRange.max} | |
| aria-valuenow={toValue(handleValues[handle])} | |
| aria-label={i === 0 ? "Minimum value" : "Maximum value"} | |
| tabIndex="0" // by setting both to zero, the browser decides the order based on DOM position | |
| /> | |
| )); | |
| return ( | |
| <> | |
| <style href={componentName}> | |
| {` | |
| @media (prefers-reduced-motion: no-preference) { | |
| [data-brrs-dragging="false"] { | |
| [data-brrs-handle], | |
| [data-brrs-range] { | |
| transition: left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, width 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; | |
| } | |
| } | |
| } | |
| `} | |
| {["hover", "active", "always"].includes(tooltip) && | |
| ` | |
| /* ${componentName} Tooltip CSS */ | |
| [data-component="${componentName}"] { | |
| --tooltip-bg: rgba(0, 0, 0, 0.6); | |
| --tooltip-gap: 0.8rem; | |
| &[data-brrs-tooltip="always"] { | |
| [data-brrs-handle]:before, | |
| [data-brrs-handle]:after { display: block; } | |
| } | |
| /* In active and hover modes, show the tooltip when handle active */ | |
| &[data-brrs-tooltip="active"], | |
| &[data-brrs-tooltip="hover"] { | |
| [data-brrs-handle][data-brrs-active="true"]:before, | |
| [data-brrs-handle][data-brrs-active="true"]:after { display: block; } | |
| } | |
| &[data-brrs-tooltip="hover"] { | |
| [data-brrs-handle]:hover:before, | |
| [data-brrs-handle]:hover:after { display: block; } | |
| } | |
| /* Alternative hover logic below */ | |
| /* On touch devices, show when active in hover mode */ | |
| /*@media (hover: none) { | |
| &[data-brrs-tooltip="hover"] { | |
| [data-brrs-handle][data-brrs-active="true"]:before, | |
| [data-brrs-handle][data-brrs-active="true"]:after { display: block; } | |
| } | |
| }*/ | |
| /* Make sure to show the tooltip while dragging in hover mode */ | |
| /*@media (hover: hover) { | |
| &[data-brrs-tooltip="hover"] { | |
| [data-brrs-handle]:hover:before, | |
| [data-brrs-handle]:hover:after { display: block; } | |
| } | |
| &[data-brrs-tooltip="hover"][data-brrs-dragging="true"] { | |
| [data-brrs-handle][data-brrs-active="true"]:before, | |
| [data-brrs-handle][data-brrs-active="true"]:after { display: block; } | |
| } | |
| }*/ | |
| [data-brrs-handle]:before { | |
| content: attr(aria-valuenow); | |
| position: absolute; | |
| display: none; | |
| pointer-events: none; | |
| /* horizontally center */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| /* move to bottom */ | |
| top: 100%; | |
| margin-top: var(--tooltip-gap); /* gap between handle and tooltip */ | |
| /* basic styles */ | |
| padding: 0.075rem 0.4rem 0.1rem 0.4rem; | |
| border-radius: 6px; | |
| background: var(--tooltip-bg); | |
| color: #fff; | |
| text-align: center; | |
| font-size: 0.875rem; | |
| } | |
| [data-brrs-handle]:after { | |
| content: ""; | |
| position: absolute; | |
| display: none; | |
| pointer-events: none; | |
| /* horizontally center */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| /* move to bottom */ | |
| top: 100%; | |
| margin-top: calc(var(--tooltip-gap) - 0.8rem); | |
| /* the arrow */ | |
| height: 0.8rem; | |
| border: 0.4rem solid #000; | |
| border-color: transparent transparent var(--tooltip-bg) transparent; | |
| } | |
| }`} | |
| </style> | |
| <div className="flex items-center justify-center min-h-screen bg-gray-100 font-sans"> | |
| <div className="w-full max-w-lg p-8 space-y-8 bg-white rounded-2xl shadow-lg"> | |
| <h1 className="text-2xl font-bold text-center text-gray-800 mb-0"> | |
| A Better React Range Slider | |
| </h1> | |
| <p className="mt-2 mb-8 text-center text-gray-600"> | |
| UX-first, without sacrificing accessibility | |
| </p> | |
| {/* Slider Component */} | |
| <div | |
| data-component={componentName} | |
| data-brrs-tooltip={tooltip} | |
| data-brrs-dragging={dragging} | |
| className="relative flex items-center h-12" | |
| > | |
| {/* Slider Track */} | |
| <div | |
| ref={trackRef} | |
| data-brrs-track | |
| className="relative w-full h-2 bg-gray-200 rounded-full cursor-pointer" | |
| // onClick fires after blur, so you can't move the currently selected handle | |
| // it's also slow on touch devices | |
| // onClick={handleTrackClick} | |
| // onMouseDown and onTouchStart fire before blur | |
| // onMouseDown={handleTrackClick} | |
| // onTouchStart={handleTrackClick} | |
| // can also use pointerdown | |
| onPointerDown={handleTrackClick} | |
| > | |
| {/* Highlighted Range Bar */} | |
| <div | |
| data-brrs-range | |
| className="absolute h-2 bg-blue-500 rounded-full pointer-events-none" | |
| style={{ | |
| left: `${rangeStart}%`, | |
| width: `${rangeWidth}%`, | |
| }} | |
| /> | |
| {/* Render handles */} | |
| {handlesHTML} | |
| </div> | |
| </div> | |
| {/* Display values for demo purposes */} | |
| <div className="flex justify-between pt-4"> | |
| <div className="text-center"> | |
| <label | |
| htmlFor="min-value" | |
| className="text-sm font-medium text-gray-500" | |
| > | |
| Min Value | |
| </label> | |
| <p id="min-value" className="text-lg font-semibold text-gray-800"> | |
| {values.min} ({sliderRange.min}) | |
| </p> | |
| </div> | |
| <div className="text-center"> | |
| <label | |
| htmlFor="max-value" | |
| className="text-sm font-medium text-gray-500" | |
| > | |
| Active Handle | |
| </label> | |
| <p id="max-value" className="text-lg font-semibold text-gray-800"> | |
| {activeHandle} | |
| </p> | |
| </div> | |
| <div className="text-center"> | |
| <label | |
| htmlFor="max-value" | |
| className="text-sm font-medium text-gray-500" | |
| > | |
| Dragging | |
| </label> | |
| <p id="max-value" className="text-lg font-semibold text-gray-800"> | |
| {dragging.toString()} | |
| </p> | |
| </div> | |
| <div className="text-center"> | |
| <label | |
| htmlFor="max-value" | |
| className="text-sm font-medium text-gray-500" | |
| > | |
| Max Value | |
| </label> | |
| <p id="max-value" className="text-lg font-semibold text-gray-800"> | |
| {values.max} ({sliderRange.max}) | |
| </p> | |
| </div> | |
| </div> | |
| {/* Hidden input fields for HTML form support */} | |
| <input | |
| type="hidden" | |
| name={ | |
| Array.isArray(inputName) && inputName.length == 2 | |
| ? inputName[0] | |
| : inputName | |
| } | |
| value={values.min} | |
| /> | |
| <input | |
| type="hidden" | |
| name={ | |
| Array.isArray(inputName) && inputName.length == 2 | |
| ? inputName[1] | |
| : inputName | |
| } | |
| value={values.max} | |
| /> | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // validate props | |
| function validateVanilla({ | |
| range, | |
| increment, | |
| value, | |
| tooltip, | |
| inputName, | |
| onChange, | |
| }) { | |
| if (!Array.isArray(range) || range.length < 2 || range.some(Number.isNaN)) | |
| console.warn( | |
| `[${componentName}] Invalid value set for 'range' prop; must be an array containing at least 2 numbers` | |
| ); | |
| if (Number.isNaN(increment)) | |
| console.warn(`[${componentName}] Invalid value set for 'increment' prop`); | |
| if (increment >= range.at(-1)) | |
| console.warn( | |
| `[${componentName}] 'increment' value must be less than max range` | |
| ); | |
| if (!Array.isArray(value) || value.length !== 2 || value.some(Number.isNaN)) | |
| console.warn( | |
| `[${componentName}] Invalid value set for 'value' prop, must be [min, max]` | |
| ); | |
| if (value.some((v) => v < range[0] || v > range.at(-1))) | |
| console.warn( | |
| `[${componentName}] 'value' min/max must be within 'range' min/max` | |
| ); | |
| if (!["hover", "active", "always", "never", "none"].includes(tooltip)) | |
| console.warn(`[${componentName}] Invalid value set for 'tooltip' prop`); | |
| if (!inputName || (Array.isArray(inputName) && inputName.length !== 2)) | |
| console.warn( | |
| `[${componentName}] Invalid value set for 'inputName' prop, must be string or array with 2 strings` | |
| ); | |
| if (onChange && typeof onChange !== "function") | |
| console.warn(`[${componentName}] 'onChange' must be nullish or a function`); | |
| } | |
| function validateZod({ | |
| range, | |
| increment, | |
| value, | |
| tooltip, | |
| inputName, | |
| onChange, | |
| }) { | |
| const schema = z.object({ | |
| range: z.array(z.number()).min(2), | |
| increment: z.number(), | |
| value: z.array(z.number()).length(2), | |
| tooltip: z.enum(["hover", "active", "always", "never", "none"]), | |
| inputName: z.union([z.string(), z.array(z.string()).length(2)]), | |
| onChange: z.optional(z.function()), | |
| }); | |
| // TODO: put these refine calls on the relevant key above | |
| // .refine( | |
| // (data) => | |
| // data.value.some((v) => v < data.range[0] || v > data.range.at(-1)), | |
| // { | |
| // message: "'value' min/max must be within 'range' min/max", | |
| // path: ["value"], | |
| // } | |
| // ) | |
| // .refine((data) => data.increment >= data.range.at(-1), { | |
| // message: "'increment' value must be less than max range", | |
| // path: ["increment"], | |
| // }); | |
| const result = schema.safeParse(arguments[0]); | |
| if (!result.success) { | |
| console.groupCollapsed(`[${componentName}] Issue with props found`); | |
| console.warn(z.prettifyError(result.error)); | |
| console.groupEnd(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment