Skip to content

RangeSlider

  • A price, age or date-span filter on a list
  • A band saved with a record: a salary range on a job post, the ages an event is for
  • Working hours, or any from–to where the rough position matters more than the exact number

Not for one value — use a Slider. Not when exact numbers matter — use two Inputs with type="number".

Price $200 – $800
import { useState } from 'react';
import { RangeSlider } from '@arun-dev/ui';
const money = (n: number) => `$${n}`;
// A price filter: the group is named by its label, each thumb by its own name, and the range is
// shown in an <output> beside the label.
export default function RangeSliderPrice() {
const [price, setPrice] = useState<readonly [number, number]>([200, 800]);
return (
<div style={{ display: 'grid', gap: 'var(--space-2xs)', maxInlineSize: '20rem' }}>
<span id="range-slider-price">
Price{' '}
<output>
{money(price[0])} – {money(price[1])}
</output>
</span>
<RangeSlider.Root
aria-labelledby="range-slider-price"
min={0}
max={1000}
step={10}
value={price}
onValueChange={setPrice}
>
<RangeSlider.StartInput
aria-label="Minimum price"
aria-valuetext={money(price[0])}
name="priceMin"
/>
<RangeSlider.EndInput
aria-label="Maximum price"
aria-valuetext={money(price[1])}
name="priceMax"
/>
</RangeSlider.Root>
</div>
);
}

RangeSlider is two native <input type="range">s, the start and the end, stacked on one track. Each thumb’s keys — the arrows, Page Up and Down, Home and End — its step, its slider role for assistive technology and its form value are the browser’s. The component adds what two inputs cannot do alone:

  • A thumb stops where the other stands, so the start never passes the end.
  • The stretch between the thumbs is filled.
  • When the thumbs meet, the one that can still move is on top: the end below the middle, the start past it.
  • A press on the track moves the nearer thumb there, and focuses it.

Each thumb is its own Tab stop, and is named “Minimum” and “Maximum” unless you name it. Name the group too, with aria-label or aria-labelledby, and give each thumb an aria-valuetext when the number alone does not say it, such as “$200”.

Each input takes its own name and submits its end. form.reset() returns the range to where it started.

Age
import { useState } from 'react';
import { Button, RangeSlider } from '@arun-dev/ui';
// Uncontrolled in a form: each end submits under its own name, and Reset returns the range to
// where it started.
export default function RangeSliderForm() {
const [sent, setSent] = useState('');
return (
<form
style={{ display: 'grid', gap: 'var(--space-sm)', maxInlineSize: '20rem' }}
onSubmit={(event) => {
event.preventDefault();
setSent(new URLSearchParams(new FormData(event.currentTarget) as never).toString());
}}
>
<span id="range-slider-age">Age</span>
<RangeSlider.Root
aria-labelledby="range-slider-age"
min={18}
max={80}
defaultValue={[25, 40]}
>
<RangeSlider.StartInput aria-label="Youngest age" name="ageMin" />
<RangeSlider.EndInput aria-label="Oldest age" name="ageMax" />
</RangeSlider.Root>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary">
Apply
</Button>
<Button type="reset">Reset</Button>
</div>
{sent && <code>{sent}</code>}
</form>
);
}

With react-hook-form, bind the whole range as one field with Controller:

<Controller
name="price"
control={control}
defaultValue={[200, 800]}
render={({ field }) => (
<RangeSlider.Root
aria-label="Price"
max={1000}
value={field.value}
onValueChange={field.onChange}
>
<RangeSlider.StartInput onBlur={field.onBlur} />
<RangeSlider.EndInput onBlur={field.onBlur} />
</RangeSlider.Root>
)}
/>
RangeSlider.Root props
PropTypeDefaultDescription
valuereadonly [number, number]Controlled range, start then end. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultValuereadonly [number, number][min, max]Initial range when uncontrolled. Read once, at mount; form.reset() returns to it.
onValueChange(value: readonly [number, number]) => voidCalled with the whole range whenever either end moves.
minnumber0The lowest value either thumb can take.
maxnumber100The highest value either thumb can take.
stepnumber1The step both thumbs move by.
disabledbooleanfalseDisables both inputs, natively, so neither is submitted.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

RangeSlider.StartInput and RangeSlider.EndInput

Section titled “RangeSlider.StartInput and RangeSlider.EndInput”

Every native input prop goes on them: name, id, aria-*, aria-valuetext, form.

RangeSlider.StartInput props
PropTypeDefaultDescription
namestringSubmits the start with the form under this name.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • The track, fill and thumbs are drawn by @arun-dev/ui, not the browser, and themed through the --range-slider-* tokens.
  • Building your own styling on @arun-dev/headless: stack the two inputs, and set pointer-events: none on each input and auto on its thumb, or the top input takes every press. The Root sets --range-slider-start and --range-slider-end, the fill’s ends as fractions of the track, and marks the thumb on top with data-raised.