Skip to content

Instantly share code, notes, and snippets.

@Bandit
Last active September 9, 2025 02:12
Show Gist options
  • Select an option

  • Save Bandit/7b879582dca84bc8ec891f4c5f023c9a to your computer and use it in GitHub Desktop.

Select an option

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
/*
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