RangeSlider
When to use
Section titled “When to use”- 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".
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> );}How it works
Section titled “How it works”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”.
In a form
Section titled “In a form”Each input takes its own name and submits its end. form.reset() returns the range to where
it started.
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
Section titled “RangeSlider.Root”| Prop | Type | Default | Description |
|---|---|---|---|
value | readonly [number, number] | Controlled range, start then end. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultValue | readonly [number, number] | [min, max] | Initial range when uncontrolled. Read once, at mount; form.reset() returns to it. |
onValueChange | (value: readonly [number, number]) => void | Called with the whole range whenever either end moves. | |
min | number | 0 | The lowest value either thumb can take. |
max | number | 100 | The highest value either thumb can take. |
step | number | 1 | The step both thumbs move by. |
disabled | boolean | false | Disables both inputs, natively, so neither is submitted. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | Submits the start with the form under this name. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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 setpointer-events: noneon each input andautoon its thumb, or the top input takes every press. The Root sets--range-slider-startand--range-slider-end, the fill’s ends as fractions of the track, and marks the thumb on top withdata-raised.