# Slider

> The native range input, styled; a group around one or two of them fills the track in every engine and makes a range with two thumbs kept in order.

Page: https://svelte-lean.vercel.app/primitives/slider · Markdown: https://svelte-lean.vercel.app/primitives/slider.md · Index: https://svelte-lean.vercel.app/llms.txt

The native range input, styled, for one value; and a group around one or two of them for a filled track in every engine and a range with two thumbs.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';

	let volume = $state(40);
</script>

<div class="field">
	<div class="head">
		<label for="slider-volume">Volume</label>
		<output for="slider-volume">{volume}</output>
	</div>
	<input
		id="slider-volume"
		type="range"
		name="volume"
		min="0"
		max="100"
		step="5"
		bind:value={volume}
		data-slean="slider"
	/>
</div>

<style>
	.field {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 20rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

Drag the thumb, or focus it and use the arrow keys, PageUp, PageDown, Home and End.

## Examples

### Range (two thumbs)

```svelte
<script lang="ts">
	// A <div data-slean="slider"> registers the slider behavior with @svelte-lean/vite.
	import '@svelte-lean/styles/slider.css';
	import { rangeStyle } from '@svelte-lean/primitives/slider';

	let min = $state(200);
	let max = $state(750);
</script>

<!-- Two inputs in a group: the range mode keeps them in order. -->
<div class="range">
	<div class="head">
		<span id="slider-price-label">Price</span>
		<span>${min} – ${max}</span>
	</div>
	<div
		role="group"
		aria-labelledby="slider-price-label"
		data-slean="slider"
		style={rangeStyle(0, 1000, min, max)}
	>
		<input
			type="range"
			name="min"
			min="0"
			max="1000"
			step="10"
			bind:value={min}
			aria-label="Minimum price"
		/>
		<input
			type="range"
			name="max"
			min="0"
			max="1000"
			step="10"
			bind:value={max}
			aria-label="Maximum price"
		/>
	</div>
</div>

<style>
	.range {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 22rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

Two range inputs in a group: each thumb stops at the other; bind:value sees the clamped value.

### Minimum gap

```svelte
<script lang="ts">
	// A <div data-slean="slider"> registers the slider behavior with @svelte-lean/vite.
	import '@svelte-lean/styles/slider.css';
	import { rangeStyle } from '@svelte-lean/primitives/slider';

	let opens = $state(9);
	let closes = $state(17);
	const time = (hour: number) => `${String(hour).padStart(2, '0')}:00`;
</script>

<!-- data-slean-gap: the two values stay at least two hours apart. -->
<div class="range">
	<div class="head">
		<span id="slider-hours-label">Opening hours</span>
		<span>{time(opens)} – {time(closes)}</span>
	</div>
	<div
		role="group"
		aria-labelledby="slider-hours-label"
		data-slean="slider"
		data-slean-gap="2"
		style={rangeStyle(0, 24, opens, closes)}
	>
		<input
			type="range"
			min="0"
			max="24"
			bind:value={opens}
			aria-label="Opens at"
			aria-valuetext={time(opens)}
		/>
		<input
			type="range"
			min="0"
			max="24"
			bind:value={closes}
			aria-label="Closes at"
			aria-valuetext={time(closes)}
		/>
	</div>
</div>

<style>
	.range {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 22rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

### Filled track

```svelte
<script lang="ts">
	// A <div data-slean="slider"> registers the slider behavior with @svelte-lean/vite.
	import '@svelte-lean/styles/slider.css';
	import { rangeStyle } from '@svelte-lean/primitives/slider';

	let zoom = $state(60);
</script>

<!-- One input in a group: the track is filled up to the thumb in every engine. -->
<div class="range">
	<div class="head">
		<label for="slider-zoom">Zoom</label>
		<output for="slider-zoom">{zoom}%</output>
	</div>
	<div
		role="group"
		aria-label="Zoom"
		data-slean="slider"
		style={rangeStyle(0, 200, 0, zoom)}
	>
		<input id="slider-zoom" type="range" min="0" max="200" step="10" bind:value={zoom} />
	</div>
</div>

<style>
	.range {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 22rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

A single input in a group: the fill is painted in every engine, not only Firefox.

### Disabled range

```svelte
<script lang="ts">
	// A <div data-slean="slider"> registers the slider behavior with @svelte-lean/vite.
	import '@svelte-lean/styles/slider.css';
	import { rangeStyle } from '@svelte-lean/primitives/slider';
</script>

<!-- Disabled inputs: the track is dimmed, the label stays readable. -->
<div class="range">
	<div class="head">
		<span id="slider-locked-label">Discount (set by your plan)</span>
		<span>20% – 60%</span>
	</div>
	<div
		role="group"
		aria-labelledby="slider-locked-label"
		data-slean="slider"
		style={rangeStyle(0, 100, 20, 60)}
	>
		<input type="range" value="20" disabled aria-label="Lowest" />
		<input type="range" value="60" disabled aria-label="Highest" />
	</div>
</div>

<style>
	.range {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 22rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

### With icons

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';

	let brightness = $state(70);
</script>

<div class="with-icons">
	<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" /></svg>
	<input
		type="range"
		min="0"
		max="100"
		bind:value={brightness}
		aria-label="Brightness"
		data-slean="slider"
	/>
	<svg viewBox="0 0 24 24" aria-hidden="true">
		<circle cx="12" cy="12" r="4" />
		<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
	</svg>
</div>

<style>
	.with-icons {
		display: flex;
		align-items: center;
		gap: var(--slean-space-3);
		max-inline-size: 20rem;
	}

	.with-icons svg {
		flex: none;
		inline-size: 1.25rem;
		block-size: 1.25rem;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
	}
</style>
```

### Steps and units

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';

	let temperature = $state(21);
</script>

<div class="field">
	<div class="head">
		<label for="slider-temperature">Temperature</label>
		<output for="slider-temperature">{temperature.toFixed(1)} °C</output>
	</div>
	<input
		id="slider-temperature"
		type="range"
		name="temperature"
		min="16"
		max="28"
		step="0.5"
		bind:value={temperature}
		data-slean="slider"
	/>
</div>

<style>
	.field {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 20rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

### Value text

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';

	let balance = $state(0);
	// The number alone does not say which side: the text is the application's job.
	const text = $derived(
		balance === 0 ? 'Centered' : `${Math.abs(balance)} to the ${balance < 0 ? 'left' : 'right'}`
	);
</script>

<div class="field">
	<div class="head">
		<label for="slider-balance">Balance</label>
		<output for="slider-balance">{text}</output>
	</div>
	<input
		id="slider-balance"
		type="range"
		min="-10"
		max="10"
		bind:value={balance}
		aria-valuetext={text}
		data-slean="slider"
	/>
</div>

<style>
	.field {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 20rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

Screen readers read aria-valuetext instead of the number.

### Native drawing with tick marks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';
</script>

<div class="field">
	<label for="slider-zoom">Zoom</label>
	<input
		id="slider-zoom"
		type="range"
		name="zoom"
		min="50"
		max="150"
		step="25"
		value="100"
		list="slider-zoom-marks"
		data-slean="slider"
		data-variant="native"
	/>
	<datalist id="slider-zoom-marks">
		<option value="50"></option>
		<option value="75"></option>
		<option value="100"></option>
		<option value="125"></option>
		<option value="150"></option>
	</datalist>
</div>

<style>
	.field {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 20rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

The platform’s own slider in the accent color, with a tick mark per datalist option.

### Vertical

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';

	const bands = $state([
		{ label: 'Bass', value: 60 },
		{ label: 'Mid', value: 35 },
		{ label: 'Treble', value: 75 }
	]);
</script>

<div class="bands">
	{#each bands as band (band.label)}
		<label class="band">
			<!-- data-slean-orientation="vertical" is writing-mode: vertical-lr; direction: rtl. -->
			<input
				type="range"
				bind:value={band.value}
				data-slean="slider"
				data-slean-orientation="vertical"
			/>
			{band.label}
		</label>
	{/each}
</div>

<style>
	.bands {
		display: flex;
		gap: var(--slean-space-6);
	}

	.band {
		display: grid;
		justify-items: center;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}
</style>
```

### Disabled

```svelte
<script lang="ts">
	import '@svelte-lean/styles/slider.css';
</script>

<div class="field">
	<label for="slider-locked">Storage limit (set by your plan)</label>
	<input id="slider-locked" type="range" value="30" disabled data-slean="slider" />
</div>

<style>
	.field {
		display: grid;
		gap: var(--slean-space-2);
		max-inline-size: 20rem;
	}

	.head {
		display: flex;
		justify-content: space-between;
		font-size: var(--slean-text-sm);
	}
</style>
```

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="slider"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/slider/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/slider.css';
```

Put `data-slean="slider"` on an `<input type="range">` with a label, `min`, `max`, `step` and a value; `bind:value` gives a number. Show the exact value in an `<output for>` when it matters, and add `aria-valuetext` when the number alone does not describe it.

For a filled track in every engine or a range, wrap one or two range inputs in `<div role="group" data-slean="slider">` named by its label, and leave `data-slean` off the inputs. Give each of two inputs its own name and render the root’s `style` from `rangeStyle()` so the fill is right before the first input. `data-slean-gap` keeps two values apart.

`data-variant="native"` keeps the platform’s drawing with the filled track and tick marks; `data-slean-orientation="vertical"` stands a bare slider up.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<input type="range" data-slean="slider">` | – | yes | A label; min, max, step, value. data-variant="native" keeps the platform drawing; data-slean-orientation="vertical". |
| root (group) | `<div role="group" aria-labelledby="…" data-slean="slider">` | – | no | Around one or two \<input type="range"\> children (no data-slean on them): a filled track, or a range. data-slean-gap; style from rangeStyle(). |
| output | `<output for="…">` | – | no | Beside a group: shows the value of the input it names; the behavior writes it. |
| marks | `<datalist id="…"><option value="…"></option></datalist>` | – | no | Named by the list attribute; tick marks on the native variant only. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `ArrowRight`/`ArrowUp` | slider focused | One step up; ArrowRight is one step down under RTL (native) |
| `ArrowLeft`/`ArrowDown` | slider focused | One step down; ArrowLeft is one step up under RTL (native) |
| `PageUp`/`PageDown` | slider focused | A tenth of the range up or down (native, Chrome) |
| `Home`/`End` | slider focused | The minimum, the maximum (native) |
| `Tab` | in a range | Moves between the two thumbs; a thumb stops at the other one |

## Styling

`slider.css` resets the input’s appearance and draws the track and the thumb with `::-webkit-slider-runnable-track` and `::-webkit-slider-thumb` (Chrome, Safari) and `::-moz-range-track`, `::-moz-range-thumb` and `::-moz-range-progress` (Firefox), with a halo on hover. A group stacks its inputs on one rail drawn on the root from `--slean-range-start` and `--slean-range-end`. The native variant restores the platform’s appearance and sets `accent-color`. The vertical option is `writing-mode: vertical-lr` with `direction: rtl`. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-control-height-sm` | `2rem` | block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-color-main` | `var(--slean-primary-main)` | accent-color, background |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-border` | `var(--slean-neutral-6)` | background |
| `--slean-surface` | `oklch(100% 0 0)` | border |
| `--slean-elevation-1` | `0 1px 2px oklch(0% 0 0 / 0.08)` | box-shadow |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-soft` | `var(--slean-primary-soft)` | box-shadow |
| `--slean-range-start` | `0` | background, z-index |
| `--slean-range-end` | `100` | background |
| `--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-progress`, `::-moz-range-thumb`, `::-moz-range-track`, `::-webkit-slider-runnable-track`, `::-webkit-slider-thumb`, `:dir(rtl)`, `:disabled`, `:first-of-type`, `:focus-visible`, `:hover`, `[type="range"]`.

Variant attributes: `data-variant` (`native`); `data-slean-orientation` (`vertical`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1549 B brotli · 1738 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input type="range">`, `::-webkit-slider-thumb`, `::-moz-range-progress`, `accent-color`, `writing-mode`, `two <input type="range"> in a group`
- **Shared listeners**: input, reset
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<input type="range">, one or two in a group`

A bare slider is Tier 0: the Vite plugin records no marker for `<input data-slean="slider">`, and the native-only fixture proves the path ships no Svelte Lean JavaScript. A group is Tier 1: shared `input` and `reset` listeners, no per-instance state.

Why this implementation

`<input type="range">` is a complete slider: pointer and touch dragging, the arrow, Page, Home and End keys, `min`, `max` and `step`, the form value, and the slider role with its value, range and orientation. A slider component rebuilds each of these with pointer events and ARIA. A bare `<input type="range" data-slean="slider">` keeps all of it and gets the styled track and thumb, with no JavaScript.

Two things the platform cannot do: fill the track up to the thumb in Chrome and Safari (only Firefox has `::-moz-range-progress`), and put two thumbs on one track. A `<div role="group" data-slean="slider">` around one or two native range inputs does both with one shared `input` listener: it paints the filled segment from two custom properties and keeps two values in order, while each input keeps its keys, its value and its form field.

### The browser owns

- pointer dragging, min, max and step
- the arrow, Page, Home and End keys
- the slider role with its value, range and orientation
- the form value and the input and change events
- the filled track in Firefox, and on its own drawing the filled track and datalist tick marks

### Svelte Lean owns

- slider.css: the track and thumb from the tokens, the hover halo, the native variant, the vertical option
- the group: a filled track in every engine, two thumbs kept in order (with a gap), outputs, the fill after a form reset
- one shared input listener for every group on the page; nothing for a bare slider

Accessibility

- The platform exposes the slider role, the value, the bounds and the orientation from each input itself; no ARIA attribute is needed for them.
- The arrow keys change the value by `step`, PageUp and PageDown by a larger amount, Home and End go to the bounds; under `dir="rtl"` ArrowLeft and ArrowRight follow the visual direction.
- A range is a `role="group"` named by its label, with two sliders named “Minimum …” and “Maximum …”; each is its own tab stop, and a thumb stops at the other.
- `aria-valuetext` replaces the number when the number is not the value’s meaning (a side, a size, a time).
- The focus ring surrounds a bare input, or the focused thumb of a group. Under a coarse pointer the thumbs grow to the touch target size.
- Forced colors: the track takes the text color and the thumb the Highlight system color.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<input type="range">` | Widely available | Not applicable |
| `Filled track` | Firefox only (::-moz-range-progress) | A bare slider shows the thumb on an unfilled track; a group fills it everywhere |
| `round() (the start thumb on top past the middle)` | Baseline newly available since May 2024 | The end thumb stays on top |
| `accent-color` | Not Baseline: Chrome 93, Firefox 92, Safari 26.2 | The native variant in the platform’s color |
| `Vertical form controls (writing-mode)` | Baseline 2024 (Chrome 124, Firefox 120, Safari 17.4) | The vertical slider is drawn horizontally |
| `Datalist tick marks` | Chrome, Firefox 109, Safari 12.1, on the native drawing only | No tick marks |

Without JavaScript

A bare slider is fully functional. In a group both inputs work and submit; two thumbs are not kept in order and the fill stays where the server painted it.

Server rendering and hydration

The `value`, `min`, `max` and `step` attributes render the initial state; the thumb is in place before any script runs. A group’s fill comes from its inline style, rendered with `rangeStyle()`.

A bare slider has nothing attached. A group is handled by the document’s shared listeners from its first input; hydration attaches nothing per group.

Compatibility

The range input is widely available. A bare slider’s filled track is drawn by Firefox only; a group fills it everywhere. The native variant takes the accent color where `accent-color` is supported (Chrome 93, Firefox 92, Safari 26.2; not Baseline). Vertical sliders through `writing-mode` are Baseline 2024. A group’s disabled look and two-thumb pointer rule use `:has()` (Baseline 2023); `round()` puts the start thumb on top past the middle (Baseline 2024).

Tests and source

- `packages/primitives/tests/slider.test.ts` Vitest: the fill, the clamp and the gap, outputs, reset, rangeStyle(), validation, 1000 roots
- `apps/playground/tests/primitives/forms.spec.ts` Playwright: keyboard, form values, :user-invalid, names and descriptions, axe
- `apps/playground/tests/primitives/widgets.spec.ts` Playwright: the slider group’s fill, clamp and binding, a disabled range, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)

- `packages/primitives/src/slider/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/slider/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/slider/behavior.ts` the group behavior and its helpers
- `packages/primitives/src/slider/register.ts` the registration module
- `packages/styles/css/slider.css` the optional stylesheet
