Dual Range Slider
Two handles select a numeric range, with live value bubbles — ideal for price filters.
Two draggable handles select a numeric range, the middle of the track highlights the selected span, and a live value bubble appears above while dragging or focused, with built-in crossover prevention and full keyboard operation — commonly used for price filters, time spans, and similar cases.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add range-dual-slider.enRange Dual Slider is a React dual-thumb range slider (a min/max price range filter) built with Tailwind CSS and Motion. Two handles control the lower and upper bounds, values snap to step, a value bubble appears above the handle while dragging or focused, and the two handles can never cross.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<RangeDualSlider/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Price range filters on e-commerce listing pages
- Floor area, rent or building age ranges on property sites
- Departure time or budget ranges for flight and hotel search
- Filtering admin reports by a numeric range such as amount, score or age
Installation
npx webberui@latest add https://webberui.com/r/range-dual-slider.en.jsonOr, once registries are configured in components.json, install it as @webberui/range-dual-slider.
Usage
import { RangeDualSlider } from "@/components/ui/range-dual-slider";
const [range, setRange] = React.useState<[number, number]>([1500, 10000]);
<RangeDualSlider
min={0}
max={20000}
step={100}
value={range}
onChange={setRange}
format={(v) => `NT$${v}`}
/>Examples
Price filter that queries after you settle
onChange fires continuously while dragging, so calling an API directly would send a burst of requests. Update state right away and delay the query.
"use client";
import * as React from "react";
import { RangeDualSlider } from "@/components/ui/range-dual-slider";
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
export function PriceFilter({ onSearch }: { onSearch: (r: [number, number]) => void }) {
const [range, setRange] = React.useState<[number, number]>([50, 500]);
React.useEffect(() => {
const id = setTimeout(() => onSearch(range), 300);
return () => clearTimeout(id);
}, [range, onSearch]);
return (
<RangeDualSlider
min={0}
max={1000}
step={10}
minGap={50}
value={range}
onChange={setRange}
format={(v) => usd.format(v)}
labels={["Minimum price", "Maximum price"]}
/>
);
}minGap={50} keeps the handles at least 50 apart, and labels become the aria-label of each handle.
Submit with a form
The component does not render an <input>. To submit it with a native form or a Server Action, add hidden fields yourself.
const [area, setArea] = React.useState<[number, number]>([500, 1500]);
<form action="/search">
<RangeDualSlider
min={200}
max={4000}
step={50}
defaultValue={[500, 1500]}
onChange={setArea}
format={(v) => `${v} sq ft`}
labels={["Minimum area", "Maximum area"]}
/>
<input type="hidden" name="areaMin" value={area[0]} />
<input type="hidden" name="areaMax" value={area[1]} />
<button type="submit">Search listings</button>
</form>Decimal steps
step can be a decimal; the component rounds to the number of decimal places in step to avoid floating-point drift, which suits a rating filter.
<RangeDualSlider
min={0}
max={5}
step={0.5}
defaultValue={[3.5, 5]}
format={(v) => `${v.toFixed(1)} stars`}
labels={["Minimum rating", "Maximum rating"]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
min | number | 0 | Minimum of the range |
max | number | 100 | Maximum of the range |
step | number | 1 | Step size; both dragging and keyboard operation snap to the step grid |
value | [number, number] | — | Controlled value: [low, high] |
defaultValue | [number, number] | [min, max] | Initial value in uncontrolled mode |
onChange | (value: [number, number]) => void | — | Callback for range changes (fires live during a drag) |
format | (value: number) => string | String | Value formatting function, applied to the bubble and aria-valuetext |
minGap | number | step | Minimum gap allowed between the two handles, preventing crossover |
labels | [string, string] | ["Minimum Value", "Maximum Value"] | Accessible labels for the two handles |
className | string | — | Forwarded to the outermost container |
Accessibility
- Both handles are
role="slider"witharia-valuemin/aria-valuemax/aria-valuenow, and their bounds narrow dynamically with the other handle's position aria-valuetextuses theformatted readable text (for example "NT$1,500"), which reads more naturally in assistive technology- Full keyboard support: arrow keys adjust one step,
PageUp/PageDownstep ten times,Home/Endjump to the ends - The value bubble also appears on keyboard focus, along with a clear focus ring
- When the user has "reduce motion" enabled at the system level, the bubble and handle scaling appear instantly with no spring animation
FAQ
What's the difference between controlled and uncontrolled?
With value, external state is the source of truth and you update it in onChange. With only defaultValue (or neither, which defaults to [min, max]), the component manages its own state and onChange still reports changes.
When does onChange fire?
Whenever the value snaps to a new step during a drag, and on every keyboard adjustment. There is no separate "on release" callback, so debounce it yourself if you need fewer requests.
Does it support keyboard and screen readers?
Yes. Both handles are role="slider": arrow keys move one step, PageUp / PageDown move ten steps, and Home / End jump to the ends. aria-valuetext uses the format output.
Can I change the colors?
The track, selected range and handle colors are Tailwind classes in the source (neutral by default, with dark mode), and className only applies to the outer container. To use brand colors, edit those classes in the source.
Does it work with the Next.js App Router?
Yes. The source already has "use client", so import it from a Server Component page; controlled usage with useState belongs in a Client Component. Dependencies are motion, clsx and tailwind-merge.