Inputs

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.en

New project or installation trouble? Start here →

Range 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.

0
100
1
1
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
minnumber0Minimum of the range
maxnumber100Maximum of the range
stepnumber1Step 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) => stringStringValue formatting function, applied to the bubble and aria-valuetext
minGapnumberstepMinimum gap allowed between the two handles, preventing crossover
labels[string, string]["Minimum Value", "Maximum Value"]Accessible labels for the two handles
classNamestring—Forwarded to the outermost container

Accessibility

  • Both handles are role="slider" with aria-valuemin / aria-valuemax / aria-valuenow, and their bounds narrow dynamically with the other handle's position
  • aria-valuetext uses the formatted readable text (for example "NT$1,500"), which reads more naturally in assistive technology
  • Full keyboard support: arrow keys adjust one step, PageUp / PageDown step ten times, Home / End jump 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.