Skip to content

Slider — the cross-framework headless slider / range

Slider is a headless, fully-accessible slider and dual-thumb range with no third-party engine behind it. It covers the whole behaviour surface: drag, keyboard, focus, role="slider", aria-value*, step/min/max, disabled, and RTL. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

The foundation is the browser's own native <input type="range">: drag (mouse and touch), keyboard, focus management, the slider ARIA role, and step/min/max bounds all come from the platform. Dual-thumb range mode is two overlapping transparent native inputs; vertical is a transform: rotate(-90deg) wrapper (so up = increase, with an explicit aria-orientation="vertical"); the colored fill is a positioned <div> underlay driven purely by value / min / max arithmetic, with no measured geometry. Rozie owns the author-side API: the two-way r-model:value, the range sort/clamp, the fill-var math, the marks + value-bubble overlays, and a thin PageUp/PageDown step augment.

Every visual value is a CSS custom property, so the slider re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5, plus the cross-browser thumb/track pseudo-element styling that native range inputs require.

The @rozie-ui/slider packages

Slider ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/slider-reactnpm i @rozie-ui/slider-reactreact/README
@rozie-ui/slider-vuenpm i @rozie-ui/slider-vuevue/README
@rozie-ui/slider-sveltenpm i @rozie-ui/slider-sveltesvelte/README
@rozie-ui/slider-angularnpm i @rozie-ui/slider-angularangular/README
@rozie-ui/slider-solidnpm i @rozie-ui/slider-solidsolid/README
@rozie-ui/slider-litnpm i @rozie-ui/slider-litlit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start

Two-way bind value and set the min / max / step scale to get a single-thumb slider. Flip on range to get a sorted [lo, hi] dual-thumb range instead:

rozie
<components>
{
  Slider: './Slider.rozie',
}
</components>

<data>
{
  volume: 50,
  priceRange: [20, 80],
}
</data>

<template>
  <!-- single thumb -->
  <Slider r-model:value="$data.volume" :min="0" :max="100" :step="1" ariaLabel="Volume" />

  <!-- dual-thumb range -->
  <Slider range r-model:value="$data.priceRange" :min="0" :max="100" ariaLabel="Price range" />
</template>

r-model:value is Rozie's two-way bind: the consumer hands Slider a value, Slider writes the new value back on every commit (drag end, keyboard, or programmatic step), and the framework reconciler picks it up with no onChange → setState wiring. In single mode value is a scalar number; in range mode it is a sorted [lo, hi] array (each thumb is neighbour-clamped, and a fresh array is written on every commit). Because value is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so a Slider is a form control ([formControl] / [(ngModel)] bind directly).

API

Props

NameTypeDefaultRuntime-updatable?Description
valueunknownnullyes (via r-model)The current value. model: true — a scalar number in single mode, a sorted [lo, hi] array in range mode. The sole model prop, so Angular emits a ControlValueAccessor.
rangeBooleanfalseyesRange mode: value becomes a sorted [lo, hi] array driven by two overlapping thumbs. The exact analog of listbox's multiple (scalar↔array).
minNumber0yesThe lower bound of the scale. Forwarded to the native input as the min attribute.
maxNumber100yesThe upper bound of the scale. Forwarded to the native input as the max attribute.
stepNumber1yesThe granularity. Forwarded as the native step attribute; every write-back is quantized to it.
orientationString"horizontal"yes'horizontal' (default) or 'vertical'. Vertical rotates the wrapper -90deg (up = increase) and sets aria-orientation="vertical" explicitly.
disabledBooleanfalseyesDisable the control (also sets the Angular CVA disabled state).
marksArray[]yesTick marks over the track — a bare value[] (positions only) or a { value, label }[] (positioned + labelled). Override rendering via the mark scoped slot.
ariaLabelStringnullyesAccessible name for each native input when there is no visible <label for>.
pageStepNumbernullyesThe PageUp/PageDown jump. nullstep × 10. Applied by a thin @keydown augment (arrows / Home / End stay native).
formatValueFunctionnullyes(value) => string — formats the value bubble and aria-valuetext. null → the raw value.
showValueBooleanfalseyesRender the value-bubble overlay (one bubble per thumb in range mode). Headless — opt-in, no default-styled bubble.

Events

EventDescription
changeFired after the value changes (drag, keyboard, or a programmatic increment/decrement). Payload { value } — a scalar in single mode, a sorted [lo, hi] array in range mode. The sole emit, funneled through one wrapper so the React prop-destructure hoists exactly once.

Imperative handle

Declared once in the source via $expose; obtained through each framework's native ref mechanism.

MethodDescription
focusMove DOM focus to the slider thumb (the native range input — in range mode, the lo thumb). Deliberately named focus, which overrides the inherited HTMLElement.focus on the Lit custom element — the public focus() handle is intended. (This inverts listbox's choice, which named its verb focusControl to avoid the override.)
incrementIncrease a thumb by one step, clamped to [min, max]. Accepts an optional thumb argument ('lo' | 'hi', default 'lo') in range mode.
decrementDecrease a thumb by one step, clamped to [min, max]. Accepts the same optional thumb argument in range mode.

Slots

SlotParamsDescription
markvalue, label, positionCustom per-mark rendering. position is the mark's percent along the track.
bubblevalueCustom value-bubble rendering (one instance per thumb in range mode). Gated by showValue. Named bubble, not value, because a slot may not share a declared prop name (a hard compile error: Svelte 5 unifies snippets and props into one $props namespace).

Theming

Every value the component renders is a --rozie-slider-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:

css
.rozie-slider {
  --rozie-slider-accent: #16a34a;
  --rozie-slider-track-height: 6px;
  --rozie-slider-thumb-size: 18px;
  --rozie-slider-track-bg: rgba(255, 255, 255, 0.18);
}

Two of the tokens are special. --rozie-slider-fill-start and --rozie-slider-fill-end are runtime-inline custom properties the component writes from value / min / max (via the fillStyle $computed) — they drive the colored fill <div> and the bubble positions, so they are not theme tokens you set yourself.

The structural rules (the overlapping native inputs, the filled-<div> underlay, the rotate-90 vertical wrapper, and the per-vendor pseudo-elements) are behavior-critical and compile per-leaf; they are not consumer-overridable. Only the cosmetic values flow through tokens.

The complete token table and the design-system bridges live on the dedicated theming page.

Keyboard

Focus a thumb (Tab), then drive it from the keyboard. Arrows, Home, and End are handled natively by <input type="range">; PageUp / PageDown are augmented so they honour your pageStep (native browsers use their own large step otherwise):

KeyAction
/ Increase the focused thumb by one step. (In a vertical slider increases — the wrapper is rotated -90deg.)
/ Decrease the focused thumb by one step.
HomeJump to min.
EndJump to max.
PageUpIncrease by pageStep (nullstep × 10), quantized + clamped. Augmented so it honours the configured pageStep.
PageDownDecrease by pageStep. Same augment.
TabMove to the next focusable element (in range mode, between the two thumbs).

In range mode each thumb is its own focusable native input; a thumb is clamped at its neighbour, so the array stays sorted however you drive it.

Accessibility

  • Each thumb is a native <input type="range">, so it carries the implicit role="slider" plus aria-valuemin / aria-valuemax / aria-valuenow derived by the browser from the min / max / step / value attributes — these are not set as aria-* attributes by hand (per MDN's slider-role guidance).
  • A vertical slider sets aria-orientation="vertical" explicitly, because a native range input always reports itself as horizontal even when visually rotated.
  • Supply an accessible name via a visible <label for> pointing at the input, or the ariaLabel prop (reflected onto each native input's aria-label). When formatValue is set, the formatted string is also surfaced as aria-valuetext.
  • Styling a native range input requires vendor pseudo-elements::-webkit-slider-thumb / ::-webkit-slider-runnable-track for WebKit/Blink and ::-moz-range-thumb / ::-moz-range-track / ::-moz-range-progress for Firefox. Each is emitted in its own rule block (never comma-combined — a single invalid vendor selector drops the whole rule on both browsers), so the thumb and track render consistently across browsers.
  • In range mode, pointer-events is disabled on the input bodies and re-enabled only on the thumb pseudo-elements, and the focused input is raised (z-index), so each thumb stays independently grabbable where the two overlap.

Pre-1.0 — APIs may change between minor versions.