sveltelean Primitives
Versionv0.2.0 GitHub

Example

A price range
Price

200 – 750

<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>
Drag either thumb, or Tab to it and use the arrow keys. A thumb stops at the other one; the outputs follow.

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/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/range-slider/register';
stylesheets
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

PartElementdata-slean-partRequiredNotes
root<div role="group" aria-labelledby="…" data-slean="range-slider">–yesOption: data-slean-gap. Inline style: --slean-range-start and --slean-range-end.
track<div>trackyesHolds both inputs; draws the rail and the fill.
start<input type="range" aria-label="…">startyesThe lower value.
end<input type="range" aria-label="…">endyesThe upper value; the same min, max and step as start.
output<output for="…">–noShows 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

KeyWhenResult
Tabin the pageMoves to the start thumb, then the end thumb
ArrowLeft/ArrowRightfocus on a thumbSteps down or up (native); stops at the other thumb
Home/Endfocus on a thumbMinimum or maximum (native), then clamped

Platform features

FeatureBaselineOutside the target
<input type="range">, <output>Widely availableNot applicable
round()Baseline 2024The end thumb stays on top where both meet
:has()Baseline 2023The 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:

TokenDefault (light)Applies to
--slean-space-20.5remgap
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-control-height-sm2remblock-size
--slean-radius-full9999pxborder-radius
--slean-mutedvar(--slean-neutral-3)background
--slean-range-start0background, z-index
--slean-accentoklch(54% 0.19 258)background, border
--slean-range-end100background
--slean-surfaceoklch(100% 0 0)background
--slean-shadow-sm0 1px 2px oklch(0% 0 0 / 0.08)box-shadow
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-offset2pxoutline-offset
--slean-control-height-md2.25remblock-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.

Opening hours

9:00 – 17:00

options
<!-- The two values stay at least 100 apart. -->
<div role="group" aria-label="Price" data-slean="range-slider" data-slean-gap="100">…</div>

Testing

Source