Range Slider in React: Single, Dual Handle, Custom Track
Build single and dual-handle range sliders in React from scratch — custom track fills, styled thumbs, accessible ARIA, and zero-dependency implementations.
Why Native <input type="range"> Always Disappoints
The browser's native range input works in 2026 — technically. It fires change events, it's keyboard-accessible, it respects min/max/step. That said, the default rendering is some of the most inconsistent CSS you'll encounter across platforms: a fat grey track on Chrome, a tiny blue one on Safari, a completely different thumb shape on Firefox 119+. If your design has any opinions at all, you're reskinning it from scratch anyway.
Honest take: In practice, the native <input type="range"> is fine for internal tools where visual consistency doesn't matter. The moment you care about a 4px track height, a custom thumb with a tooltip, or a gradient fill between two handles, you need to build your own. This article does exactly that — from a simple single-handle slider to a full dual-handle price-range picker, no external packages required.
Worth noting: dual-handle sliders aren't natively supported at all. There's no <input type="range" dual>. You're always building that yourself, which is why so many teams reach for libraries like rc-slider or Radix. We'll cover when those are worth it, but first you need to understand what you're actually building.
Building a Single-Handle Slider from Scratch
Start simple. A single range slider is just a controlled <input type="range"> with a custom CSS layer on top. The trick to the colored fill — that gradient that shows progress from left to thumb — is a CSS linear-gradient applied to the track via a custom property you update on every value change.
import { useState, useCallback } from 'react';
interface SliderProps {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange?: (value: number) => void;
}
export function Slider({
min = 0,
max = 100,
step = 1,
defaultValue = 50,
onChange,
}: SliderProps) {
const [value, setValue] = useState(defaultValue);
const pct = ((value - min) / (max - min)) * 100;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
setValue(v);
onChange?.(v);
},
[onChange]
);
return (
<div className="relative w-full">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
className="slider-track w-full appearance-none h-1.5 rounded-full cursor-pointer"
style={{
background: `linear-gradient(
to right,
#6366f1 0%,
#6366f1 ${pct}%,
#e2e8f0 ${pct}%,
#e2e8f0 100%
)`,
}}
aria-label="Value"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
/>
<span className="mt-2 block text-sm text-slate-600">{value}</span>
</div>
);
}The pct calculation is the whole trick. Every time the value changes, you recalculate that percentage and feed it straight into the inline background gradient. No canvas, no SVG, no third-party library. You'll also want to kill the browser's default thumb styling — drop this into your global CSS:
/* globals.css */
.slider-track::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #6366f1;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
cursor: grab;
}
.slider-track::-webkit-slider-thumb:active {
cursor: grabbing;
transform: scale(1.1);
}
.slider-track::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #6366f1;
border: 2px solid white;
cursor: grab;
}Quick aside: that 20px thumb is deliberate. WCAG 2.1 recommends a 44×44px touch target minimum, so you'd want to add a larger invisible hit area via padding or a pseudo-element if this is going on mobile. The 20px visual size looks clean on desktop without feeling comically large.
Dual-Handle Slider: The Price Range Pattern
Dual-handle sliders show up everywhere — price filters on e-commerce, date range pickers, salary selectors on job boards. The core idea is two overlapping <input type="range"> elements stacked with position: absolute, sharing the same track. The lower-value thumb always stays left of the upper-value thumb.
import { useState, useCallback } from 'react';
interface RangeSliderProps {
min?: number;
max?: number;
step?: number;
defaultMin?: number;
defaultMax?: number;
onChange?: (range: [number, number]) => void;
}
export function RangeSlider({
min = 0,
max = 1000,
step = 10,
defaultMin = 200,
defaultMax = 800,
onChange,
}: RangeSliderProps) {
const [low, setLow] = useState(defaultMin);
const [high, setHigh] = useState(defaultMax);
const pctLow = ((low - min) / (max - min)) * 100;
const pctHigh = ((high - min) / (max - min)) * 100;
const handleLow = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = Math.min(Number(e.target.value), high - step);
setLow(v);
onChange?.([v, high]);
},
[high, step, onChange]
);
const handleHigh = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = Math.max(Number(e.target.value), low + step);
setHigh(v);
onChange?.([low, v]);
},
[low, step, onChange]
);
const trackStyle = {
background: `linear-gradient(
to right,
#e2e8f0 ${pctLow}%,
#6366f1 ${pctLow}%,
#6366f1 ${pctHigh}%,
#e2e8f0 ${pctHigh}%
)`,
};
return (
<div className="relative w-full">
<div
className="relative h-1.5 rounded-full"
style={trackStyle}
>
{/* Low handle */}
<input
type="range"
min={min} max={max} step={step} value={low}
onChange={handleLow}
className="absolute inset-0 w-full appearance-none bg-transparent
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-5
[&::-webkit-slider-thumb]:h-5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-2
[&::-webkit-slider-thumb]:border-indigo-500
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:cursor-grab
pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto"
aria-label="Minimum value"
aria-valuenow={low}
aria-valuemin={min}
aria-valuemax={high}
/>
{/* High handle */}
<input
type="range"
min={min} max={max} step={step} value={high}
onChange={handleHigh}
className="absolute inset-0 w-full appearance-none bg-transparent
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-5
[&::-webkit-slider-thumb]:h-5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-2
[&::-webkit-slider-thumb]:border-indigo-500
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:cursor-grab
pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto"
aria-label="Maximum value"
aria-valuenow={high}
aria-valuemin={low}
aria-valuemax={max}
/>
</div>
<div className="mt-3 flex justify-between text-sm text-slate-600">
<span>${low.toLocaleString()}</span>
<span>${high.toLocaleString()}</span>
</div>
</div>
);
}The pointer-events-none on the track containers with pointer-events-auto back on the thumbs is the key to making both inputs sit on top of each other and still respond correctly to clicks. Without that, only the top-most input would ever receive events.
One more thing — the Math.min and Math.max guards in handleLow and handleHigh stop the handles from crossing. You always want at least one step of separation between them so neither handle disappears behind the other. Adjust that gap based on your domain: for a price slider with a $10 step, low + step as the minimum for high means prices can never be equal, which usually makes sense.
Custom Track Fills and Gradient Styling
The four-stop gradient pattern — grey / active-color / active-color / grey — handles both single and dual fills cleanly, but you're not limited to solid colors. Want a gradient fill that shifts from green to yellow to red across the track? Just replace the active color with a more complex gradient segment.
// A temperature-style gradient track: blue → green → red
const gradientFill = `linear-gradient(
to right,
#94a3b8 0%,
#94a3b8 ${pctLow}%,
/* active region: cool → warm */
#3b82f6 ${pctLow}%,
#22c55e ${(pctLow + pctHigh) / 2}%,
#ef4444 ${pctHigh}%,
/* inactive right */
#94a3b8 ${pctHigh}%,
#94a3b8 100%
)`;Honestly, this approach breaks down if you want a truly complex fill — say a histogram distribution bar underneath the track. For that you'd replace the CSS gradient with an absolutely-positioned SVG or canvas element that draws the distribution, and layer the two input thumbs on top with pointer-events tricks. That's worth doing for advanced filtering UIs like Airbnb's price histogram slider.
If you want a visual shortcut while prototyping, the gradient generator on Empire UI lets you build the color stop values visually and copy the CSS output. It's genuinely faster than tweaking hex values in code and re-checking DevTools. Same for track shadows — the box shadow generator is useful for the subtle depth on the track container.
Track height matters more than you'd think. A 4px track reads as a precision instrument; an 8px track feels chunky and touchscreen-friendly. Pick based on your context — mobile product filter vs desktop settings panel are different problems. Don't let anyone tell you there's a universal answer there.
Accessibility You Actually Need to Ship
Native <input type="range"> already gets you keyboard support for free: arrow keys move the thumb, Page Up/Page Down jump by 10 units, Home/End go to min/max. Don't break that. The moment you replace the input with a <div> for styling reasons, you've thrown away all of that and have to reimplement it manually with onKeyDown.
ARIA attributes are non-negotiable for dual-handle sliders. Each thumb needs role="slider" (or is an input, which implies it), aria-label describing which handle it is, aria-valuenow updated on every change, aria-valuemin and aria-valuemax scoped to the current valid range for that handle — not the global min/max. Screen readers announce the handle's constraints, so aria-valuemax={high} on the low handle tells a screen reader user they can only go as high as the current upper handle position. That's actually useful context.
// Correct ARIA for dual handles
<input
aria-label="Minimum price"
aria-valuenow={low}
aria-valuemin={min}
aria-valuemax={high} // not max — current upper bound
/>
<input
aria-label="Maximum price"
aria-valuenow={high}
aria-valuemin={low} // not min — current lower bound
aria-valuemax={max}
/>One thing teams consistently skip: focus styles. The browser's default outline on a range input is usually invisible or ugly. Add a visible focus ring around the thumb — outline: 2px solid #6366f1; outline-offset: 2px when focused — so keyboard users know which handle is active. You can scope this with :focus-visible to avoid showing it on mouse clicks if that's your preference.
When to Reach for a Library Instead
The scratch-built approach works great for straightforward cases. But there are signals that you should just grab @radix-ui/react-slider or rc-slider instead of rolling your own. If you need: a vertical slider, marks/labels at specific tick positions, a slider with more than two handles, or a value tooltip that precisely follows the thumb — you're looking at another 300+ lines of JS to get that right.
Radix UI's Slider (version 1.1.x) is the best-maintained option right now. It handles all the ARIA correctly, supports multiple thumbs via an array value prop, and is headless — you style it entirely with your own CSS. The downside is it doesn't support the gradient track fill pattern out of the box; you compute that yourself based on the value array it returns.
import * as RadixSlider from '@radix-ui/react-slider';
export function PriceFilter() {
return (
<RadixSlider.Root
className="relative flex items-center w-full h-5"
defaultValue={[200, 800]}
min={0} max={1000} step={10}
>
<RadixSlider.Track className="bg-slate-200 relative grow h-1.5 rounded-full">
<RadixSlider.Range className="absolute bg-indigo-500 h-full rounded-full" />
</RadixSlider.Track>
<RadixSlider.Thumb
className="block w-5 h-5 bg-white border-2 border-indigo-500 rounded-full shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
aria-label="Minimum price"
/>
<RadixSlider.Thumb
className="block w-5 h-5 bg-white border-2 border-indigo-500 rounded-full shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
aria-label="Maximum price"
/>
</RadixSlider.Root>
);
}Look, Radix adds about 12kB to your bundle for the slider primitive. For a component used in a filter sidebar that's fine. For a landing page with a single interactive slider, the scratch build is the smarter call. Know your context.
If you're building out a full design system and want consistent interactive components without a heavy dependency, the Empire UI component library has interactive primitives — including form controls — that you can browse and copy directly. Check the blog for related component breakdowns too.
Integrating with React Hook Form and Controlled Forms
Range sliders almost always live inside a form — you're filtering products, configuring settings, setting a budget. That means you need them to play nicely with form state. React Hook Form is the go-to in 2026 and the integration is painless with Controller.
import { useForm, Controller } from 'react-hook-form';
import { RangeSlider } from './RangeSlider';
type FilterForm = {
priceRange: [number, number];
};
export function FilterSidebar() {
const { control, handleSubmit } = useForm<FilterForm>({
defaultValues: { priceRange: [100, 900] },
});
const onSubmit = (data: FilterForm) => {
console.log('Filter:', data.priceRange);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Price Range
</label>
<Controller
name="priceRange"
control={control}
render={({ field }) => (
<RangeSlider
defaultMin={field.value[0]}
defaultMax={field.value[1]}
onChange={(range) => field.onChange(range)}
/>
)}
/>
</div>
<button
type="submit"
className="w-full py-2 px-4 bg-indigo-600 text-white rounded-lg"
>
Apply Filter
</button>
</form>
);
}The Controller wrapper gives RHF visibility into the slider's value without you having to wire up register manually on a non-standard input. One thing to watch: if you're using validation rules with min/max on a tuple value, you'll need a custom validator function — RHF's built-in min/max rules work on scalar numbers, not arrays.
Worth noting: if you're doing live filtering (updating results as the slider moves, not on submit), debounce the onChange call before hitting your API. Something like 300ms with useDebouncedCallback from use-debounce will save you a lot of unnecessary requests and keeps the UI snappy even on slow connections.
FAQ
Guard each handler with a Math.min/Math.max check — const v = Math.min(Number(e.target.value), high - step) for the low handle. This keeps at least one step of separation between them at all times.
Each browser renders the default track and thumb with completely different styles. Use appearance: none and write your own pseudo-element rules targeting ::-webkit-slider-thumb for Chromium and ::-moz-range-thumb for Firefox — both browsers require separate declarations.
Yes — wrap it in RHF's Controller component and pass the field.onChange callback to your slider's onChange prop. This keeps your form state synced without needing a register call on a non-standard input element.
Build your own for simple single or dual-handle cases with straightforward styling — it's under 80 lines. Reach for @radix-ui/react-slider when you need marks, vertical orientation, more than two handles, or don't want to hand-roll accessibility.