Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Building an accessible price range filter in React

A dual-thumb price slider that works with a keyboard, a screen reader and a form, without fetching on every pixel of a drag. Every shop has one: a slider with two thumbs, "$200 – $800", and a product list that updates w

A dual-thumb price slider that works with a keyboard, a screen reader and a form, without fetching on every pixel of a drag.

Every shop has one: a slider with two thumbs, "$200 – $800", and a product list that updates when you let go. It looks like a ten-minute job. It isn't.

The usual first attempt is two <input type="range"> elements stacked on top of each other with some CSS to hide one track. That gets you surprisingly far, and then the details arrive:

  • The thumbs cross each other, or get stuck when both sit at the same end.
  • Clicking the track moves the wrong thumb.
  • The product list refetches forty times during one drag.
  • A screen reader announces two unlabeled sliders, both reading "500".
  • The tooltips overlap into an unreadable smudge when the thumbs are close.

I maintain react-multi-input-rangeslider, a small range slider for React that exists because I kept solving those same problems. This post walks through building a real price filter with it, and what each piece is for.

Install

npm install react-multi-input-rangeslider

It has no runtime dependencies, ships its own TypeScript types, and works with React 16.8 through 19, including server rendering and the Next.js App Router.

The basic filter

import { useState } from "react";
import MultiRangeSlider from "react-multi-input-rangeslider";
import "react-multi-input-rangeslider/style.css";

export default function PriceFilter() {
  const [range, setRange] = useState({ min: 200, max: 800 });

  return (
    <MultiRangeSlider
      min={0}
      max={1000}
      step={10}
      value={range}
      onChange={setRange}
      formatValue={(n) => `$${n}`}
      tooltip
      ariaLabel="Price range"
    />
  );
}

That is a controlled slider: value comes from state and onChange reports the new { min, max }. Because it is controlled, a "Reset filters" button is just setRange({ min: 0, max: 1000 }).

Fetch when the user lets go, not while they drag

onChange fires on every movement, which is what you want for updating the label. It is not what you want for hitting your API.

There is a second callback, onChangeEnd, that fires once when a drag, a key press or an assistive-technology adjustment is committed:

<MultiRangeSlider
  min={0}
  max={1000}
  step={10}
  value={range}
  onChange={setRange}
  onChangeEnd={(v) => fetchProducts({ minPrice: v.min, maxPrice: v.max })}
  ariaLabel="Price range"
/>

No debounce timer to tune. The label follows the thumb live, and the network request happens once.

Format prices properly

Hard-coding $ breaks the moment you sell in another currency. The format prop takes Intl.NumberFormat options, and locale picks the conventions:

<MultiRangeSlider
  min={0}
  max={1000}
  step={10}
  defaultValue={{ min: 200, max: 800 }}
  format={{ style: "currency", currency: "EUR", maximumFractionDigits: 0 }}
  locale="de-DE"
  tooltip
/>

The formatted text is used everywhere a value appears: the values row, the tooltips, and aria-valuetext, so a screen reader says "200 €" rather than "200".

Keep the thumbs apart

A range of "$500 – $500" usually returns nothing. minGap sets the minimum distance between the thumbs:

<MultiRangeSlider min={0} max={1000} step={10} minGap={50} defaultValue={{ min: 200, max: 800 }} />

By default a thumb simply stops when it reaches its neighbour. If you would rather have it push the other thumb along, or pass through it, set collision="push" or collision="swap".

Marks and tooltips

Tick marks give people a sense of scale. Pass marks as true for four intervals, a number for that many, or an array for custom labels:

<MultiRangeSlider
  min={0}
  max={1000}
  step={10}
  defaultValue={{ min: 200, max: 800 }}
  marks={[
    { value: 0, label: "$0" },
    { value: 500, label: "$500" },
    { value: 1000, label: "$1000" },
  ]}
  tooltip="always"
/>

With tooltip="always", two tooltips that get close merge into one label ("$480 – $520") instead of overlapping, and they never overflow the ends of the slider.

It works in a plain form

Not every filter is driven by client state. Give the slider a name and it submits with the form like any other field. A two-thumb slider submits price-min and price-max:

<form action="/products">
  <MultiRangeSlider name="price" min={0} max={1000} step={10} defaultValue={{ min: 200, max: 800 }} />
  <button type="submit">Apply</button>
</form>

That produces /products?price-min=200&price-max=800, with no JavaScript handler involved.

What you get for accessibility

This is the part that is hardest to do by hand, so it is worth spelling out. Each thumb contains a real native range input, which means:

  • Keyboard: arrow keys move one step, Shift + arrow or Page Up / Page Down move a large step, Home and End jump to the limits. Arrow keys follow the text direction in RTL.
  • Screen readers: the slider is a labelled group (ariaLabel), and the thumbs are named "Minimum value" and "Maximum value" by default. Change them with minAriaLabel / maxAriaLabel.
  • Touch: thumbs keep a 24px grab area even at the small size.
  • System settings: forced-colors mode and reduced motion are respected.

Make it match your design

There are four built-in looks (default, minimal, soft and material) in three sizes, each with light and dark themes:

<MultiRangeSlider variant="material" size="lg" theme="auto" defaultValue={{ min: 20, max: 80 }} />

Everything visual is a CSS variable, so brand colours take three lines:

.price-slider {
  --rs-range-color: #db2777;
  --rs-thumb-size: 28px;
  --rs-track-height: 10px;
}

If you use Tailwind v4, import the layered stylesheet after Tailwind and override individual parts with utilities:

@import "tailwindcss";
@import "react-multi-input-rangeslider/style.layer.css";
<MultiRangeSlider
  defaultValue={{ min: 20, max: 80 }}
  classNames={{ range: "bg-pink-500", thumb: "size-8 border-pink-500" }}
/>

There is also an unstyled mode if you want to style every part yourself.

Not only for prices

The same component handles one thumb or several. RangeSlider takes the value in whatever shape you pass and reports it back in the same shape:

import { RangeSlider } from "react-multi-input-rangeslider";

<RangeSlider defaultValue={40} />           // one thumb: a number
<RangeSlider defaultValue={[200, 800]} />   // two thumbs: an array
<RangeSlider defaultValue={[10, 40, 70]} /> // three or more

Try it

If something behaves oddly in your setup, open an issue. Edge cases in sliders are exactly what I want to hear about.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.