Primitives Forms
Range slider
Two values on one track, such as a price range. Each thumb is a native range input with its own keyboard, value and form field; the behavior keeps them in order and paints the segment between them. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1291 B brotli · 1426 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<input type="range">,<output for>,::-webkit-slider-thumb,::-moz-range-thumb,round()- Shared listeners
- input
- Per-instance listeners
- none
- Lazy state
- none
- Native base
two <input type="range">
On this page
Example
<div role="group" aria-labelledby="range-price-label" data-slean="range-slider"
style="--slean-range-start: 20; --slean-range-end: 75">
<span id="range-price-label">Price</span>
<div data-slean-part="track">
<input id="range-price-min" type="range" name="min" min="0" max="1000" step="10" value="200"
aria-label="Minimum price" data-slean-part="start" />
<input id="range-price-max" type="range" name="max" min="0" max="1000" step="10" value="750"
aria-label="Maximum price" data-slean-part="end" />
</div>
<p><output for="range-price-min">200</output> – <output for="range-price-max">750</output></p>
</div><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="range-slider".
import '@svelte-lean/styles/range-slider.css';
import { rangeStyle } from '@svelte-lean/primitives/range-slider';
let min = $state(200);
let max = $state(750);
</script>
<div
role="group"
aria-labelledby="price-label"
data-slean="range-slider"
style={rangeStyle(0, 1000, min, max)}
>
<span id="price-label">Price</span>
<div data-slean-part="track">
<input type="range" name="min" min="0" max="1000" step="10" bind:value={min}
aria-label="Minimum price" data-slean-part="start" />
<input type="range" name="max" min="0" max="1000" step="10" bind:value={max}
aria-label="Maximum price" data-slean-part="end" />
</div>
<p>{min} – {max}</p>
</div>Why this implementation exists
The platform has a slider but no two-thumb slider. Two range inputs stacked on one track are almost one: each keeps its keyboard, pointer, step, value and form field, and is announced as a slider with its value. Component libraries rebuild all of that in JavaScript around a pair of divs.
What two inputs cannot do alone is stay in order and show the range between them. One shared input listener does both: when a thumb passes the other, its value is set back and a new input event carries the clamped value to any binding; the two percentages are written as custom properties on the root, and the stylesheet paints the rail from them.
The browser owns
- two sliders: keyboard, pointer, step, min and max, each announced with its value
- both form values, under their own names
- the association of each <output for> with its input
Svelte Lean owns
- keeping start ≤ end, with an optional gap: a thumb stops at the other
- painting the filled segment from two custom properties on the root
- writing the outputs; rangeStyle() for the server, syncRange() after values set from code
- range-slider.css: two inputs stacked on one track, the thumbs, the rail and the fill
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="range-slider"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/range-slider/register';import '@svelte-lean/styles/range-slider.css';Give both inputs the same min, max and step, a name each for the form, and an accessible name each (“Minimum price”, “Maximum price”). Name the group with a visible label.
Render the root’s inline style with rangeStyle(min, max, start, end) so the fill is right before the first input; with bind:value in Svelte, derive it from the bound values. After setting values from code, call syncRange(root).
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div role="group" aria-labelledby="…" data-slean="range-slider"> | – | yes | Option: data-slean-gap. Inline style: --slean-range-start and --slean-range-end. |
| track | <div> | track | yes | Holds both inputs; draws the rail and the fill. |
| start | <input type="range" aria-label="…"> | start | yes | The lower value. |
| end | <input type="range" aria-label="…"> | end | yes | The upper value; the same min, max and step as start. |
| output | <output for="…"> | – | no | Shows the value of the input its for attribute names. |
Runtime profile
The range slider registers one input handler with the shared router. A thousand sliders keep one listener (tests/range-slider.test.ts).
Accessibility contract
- Each thumb is a native slider, announced with its own name and value; the group is named by its label.
- Tab reaches the start thumb, then the end thumb; the arrow keys, Home and End are native.
<output for>ties each displayed value to its input.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | in the page | Moves to the start thumb, then the end thumb |
| ArrowLeft/ArrowRight | focus on a thumb | Steps down or up (native); stops at the other thumb |
| Home/End | focus on a thumb | Minimum or maximum (native), then clamped |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<input type="range">, <output> | Widely available | Not applicable |
round() | Baseline 2024 | The end thumb stays on top where both meet |
:has() | Baseline 2023 | The disabled slider is not dimmed as a whole |
Without JavaScript
Both inputs work and submit their values; the thumbs are not kept in order, so the form handler should accept a start above the end, and the fill stays where the server painted it.
Server rendering
Render the values and the root's inline style from rangeStyle(); the page is correct before hydration. A Content Security Policy without style-src-attr 'unsafe-inline' blocks that attribute: the fill then spans the track until the first input, since the behavior writes through the CSSOM.
Before hydration
Before the behavior loads, the thumbs move and submit but are not kept in order. The first input event after hydration clamps and repaints.
Styling
range-slider.css stacks the two inputs on the track, lets only their thumbs take the pointer, draws the rail and the fill from --slean-range-start and --slean-range-end (mirrored under :dir(rtl)), and puts the start thumb on top where both meet past the middle. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | gap |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-control-height-sm | 2rem | block-size |
--slean-radius-full | 9999px | border-radius |
--slean-muted | var(--slean-neutral-3) | background |
--slean-range-start | 0 | background, z-index |
--slean-accent | oklch(54% 0.19 258) | background, border |
--slean-range-end | 100 | background |
--slean-surface | oklch(100% 0 0) | background |
--slean-shadow-sm | 0 1px 2px oklch(0% 0 0 / 0.08) | box-shadow |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-offset | 2px | outline-offset |
--slean-control-height-md | 2.25rem | block-size |
State selectors the stylesheet targets, all from the platform or ARIA: ::-moz-range-thumb, ::-moz-range-track, ::-webkit-slider-runnable-track, ::-webkit-slider-thumb, :dir(rtl), :disabled, :focus-visible.
Compatibility notes
Range inputs, outputs and the thumb pseudo-elements are widely available. round() (Baseline 2024) decides which thumb is on top where both meet; without it the end thumb stays on top. Vertical sliders and marks along the track are not part of the primitive.
Examples
A minimum gap
data-slean-gap keeps the two values that many units apart: a thumb stops before the other
one instead of on it.
<!-- The two values stay at least 100 apart. -->
<div role="group" aria-label="Price" data-slean="range-slider" data-slean-gap="100">…</div>Testing
packages/primitives/tests/range-slider.test.tsVitest: fill, outputs, clamp, gap, rangeStyle(), 1000 rootsapps/playground/tests/primitives/widgets.spec.tsPlaywright: tooltip, toast, tree, range slider and number field in Chrome, with axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/range-slider/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/range-slider/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/range-slider/behavior.tsthe behavior definitionpackages/primitives/src/range-slider/register.tsthe registration modulepackages/styles/css/range-slider.cssthe optional stylesheet