RangeSlider
Dual-thumb range slider with keyboard support and non-crossing thumbs.
Live demo
01Basic range (0β100, step 1)
Drag a thumb or focus it and use the Arrow keys.
Selected: 20 β 80
Price filter (Β£0βΒ£1,000, formatted)
A custom format renders currency in the bubbles and to screen readers.
Budget: Β£250 β Β£750
Year window (large step via Shift+Arrow)
Step is 1 year; Shift+Arrow jumps 5 years.
Window: 2005 β 2018
Disabled
Interaction is blocked and the thumbs leave the tab order.
Implementation
02<script lang="ts">
import RangeSlider from '$lib/components/RangeSlider.svelte';
let price = $state({ min: 20, max: 80 });
</script>
β
<RangeSlider bind:value={price} min={0} max={100} step={5} label="Price range" />Two role="slider" thumbs share one track. Their positions are derived percentages fed straight into style:left, and the coloured fill spans the gap between them. setThumb clamps each thumb against the other thumbβs current value (not the track bound), so they can never cross β the same live values feed aria-valuemin/aria-valuemax so screen readers always hear the true range.
Logic explainer
03What Does It Do? (Plain English)
RangeSlider lets a person pick a span of values β a price band, an age bracket, a date window β rather than a single point. Two draggable thumbs sit on one track, and the colour between them shows the selected range at a glance. The lower thumb can never overtake the upper one (and vice versa), so the range stays valid no matter how hard you push.
Think of it as the twin-handle filter you see on shopping sites: "show me items between Β£20 and Β£80". You can drag either handle with a mouse or finger, or nudge it precisely with the keyboard.
How It Works (Pseudo-Code)
state:
value = { min, max } // the selected range, bindable
activeThumb = null // 'min' | 'max' while dragging
derived:
minPercent = (value.min - min) / (max - min) * 100
maxPercent = (value.max - min) / (max - min) * 100
// fill spans minPercent β maxPercent
setThumb(which, raw):
v = snap(raw) // round to step, clamp to [min, max]
if which == 'min': value.min = MIN(v, value.max) // can't cross up
if which == 'max': value.max = MAX(v, value.min) // can't cross down
events:
on pointerdown thumb: activeThumb = which; capture pointer
on pointermove: if activeThumb: setThumb(activeThumb, valueAt(clientX))
on pointerup: release pointer; activeThumb = null
on track pointerdown (not on a thumb):
move the NEAREST thumb to the click point
on keydown thumb:
ArrowRight/Up β +step (Shift β +largeStep)
ArrowLeft/Down β -step (Shift β -largeStep)
PageUp/PageDown β Β±largeStep
Home β floor (overall min, or other thumb)
End β ceiling (overall max, or other thumb)The Core Concept: Non-Crossing Clamp
The whole correctness of a dual-thumb slider rests on one rule: a thumb's clamp ceiling/floor is the other thumb's current value, not the track's bound. This is enforced in setThumb rather than as a post-hoc validation pass:
min thumb: value.min = MIN(snapped, value.max)
max thumb: value.max = MAX(snapped, value.min)Worked example with step = 5, current { min: 48, max: 50 }, pressing ArrowRight on the lower thumb:
raw = 48 + 5 = 53
snapped = 50 (53 clamped to overall max would be 53, but...)
value.min = MIN(53, value.max=50) = 50 β parks against the upper thumbThe thumb stops exactly at the neighbour instead of jumping past or freezing one step short. The same MIN/MAX pair also keeps the ARIA contract honest: the lower thumb advertises aria-valuemax={value.max} and the upper advertises aria-valuemin={value.min}, so a screen reader always hears the live range, not a static bound.
CSS Animation Strategy
Thumbs and the fill are positioned with a percentage left (and the fill with a percentage width), so the layout is purely declarative β the $derived percentages flow straight into style:left. A short transition: left 0.08s ease smooths keyboard nudges and track-clicks without lagging behind a live drag (80ms is below the threshold where a dragging finger feels rubber-banded).
Value bubbles fade in via opacity on hover / focus / active, never via display, so they stay in the accessibility tree and animate cheaply. Everything is transform/opacity/left β no layout-thrashing top animation. Under prefers-reduced-motion: reduce all three transitions collapse to none, so thumbs snap instantly.
State Flow Diagram
βββββββββββββββ
β idle β
ββββββββ¬βββββββ
pointerdown β β² pointerup / pointercancel
on a thumb βΌ β
βββββββββββββββ β
β dragging βββββββ
β activeThumb β
ββββββββ¬βββββββ
pointermove β (loop)
βΌ
setThumb β value clamped, fill + bubble update
idle ββkeydown (Arrow/Page/Home/End)βββΆ setThumb βββΆ idle
idle ββpointerdown on bare trackββββββΆ nearest thumb jumps βββΆ idleProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
{ min: number; max: number } |
{ min: 20, max: 80 } |
Bindable selected range. Use bind:value. |
min |
number |
0 |
Lowest selectable value on the track. |
max |
number |
100 |
Highest selectable value on the track. |
step |
number |
1 |
Granularity for an Arrow key or a drag tick. |
largeStep |
number |
step * 10 |
Granularity for Shift+Arrow and PageUp/PageDown. |
label |
string |
'Range' |
Group label; each thumb's aria-label derives from it. |
format |
(value: number) => string |
String(value) |
Formats bubble text and aria-valuetext (e.g. currency). |
disabled |
boolean |
false |
Disables pointer + keyboard and removes thumbs from tab order. |
class |
string |
'' |
Extra classes for the root element. |
Edge Cases
| Case | Behaviour |
|---|---|
| Thumbs pushed into each other | They clamp to equal values; neither crosses. The range can be zero-width. |
| Click on the bare track | The nearest thumb (by distance) jumps to the click point; ties favour the lower thumb. |
Fractional step (e.g. 0.1) |
snap rounds to the step then trims floating-point dust with toFixed(6). |
Value passed outside [min, max] |
Any committed change is clamped back into range by snap. |
disabled set |
Pointer events ignored, thumbs get tabindex="-1" and aria-disabled="true", opacity dims. |
max === min (degenerate track) |
Percentages guard against divide-by-zero in valueFromClientX; thumbs sit at 0%. |
| Drag released outside the component | Pointer capture keeps the thumb tracking until pointerup, wherever the cursor ends up. |
Dependencies
Zero external dependencies. Pure Svelte 5 runes, scoped CSS, and the native Pointer Events API (setPointerCapture / releasePointerCapture) β no drag library, no icon set.
File Structure
src/lib/components/
RangeSlider.svelte β component (runes + scoped CSS)
RangeSlider.md β this explainer
RangeSlider.test.ts β vitest coverage (ARIA, keyboard, clamp)
src/routes/rangeslider/
+page.svelte β demo wrapped in ComponentPageShellAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
value | { min: number; max: number } | { min: 20, max: 80 } | Bindable selected range. Use bind:value. |
min | number | 0 | Lowest selectable value. |
max | number | 100 | Highest selectable value. |
step | number | 1 | Arrow-key / drag granularity. |
largeStep | number | step * 10 | Shift+Arrow and PageUp/PageDown granularity. |
label | string | 'Range' | Group label; each thumb's aria-label derives from it. |
format | (value: number) => string | String(value) | Formats bubble text and aria-valuetext. |
disabled | boolean | false | Disables interaction; removes thumbs from tab order. |
class | string | '' | Extra classes for the root element. |