Single or dual thumb, marks, tooltips, vertical, and real form participation — zero dependencies, full keyboard and screen-reader support. Drag anything below.
Drag the thumb, or press the track to jump. Arrow keys work too.
new Slider('#basic', {
value: 40, suffix: '%',
onInput: v => show(v)
})
Dual thumb with $ formatting and always-on bubbles.
Thumbs clamp at each other.
new Slider('#price', {
min: 0, max: 1000, step: 10,
value: [200, 650],
prefix: '$', tooltip: 'always'
})
Snaps to the step grid; labeled marks under the rail.
new Slider('#ram', {
min: 0, max: 64, step: 16, value: 16,
marks: { 0:'0', 16:'16', 32:'32', 48:'48', 64:'64 GB' },
format: v => v + ' GB'
})
vertical: true — bottom to top; ArrowUp still increases.
new Slider('#vol', { value: 65, vertical: true })
new Slider('#temp', { min: -10, max: 40, value: 20,
vertical: true, suffix: '°C', tooltip: 'always' })
The <input> is hidden and kept synced — submit
posts real values (dual adds budget[] hidden inputs).
new Slider(document.getElementById('native-volume'))
new Slider('#budget', { name: 'budget', prefix: '$',
max: 500, value: [100, 350] })
disabled: true, toggled live via
enable()/disable().
var s = new Slider('#locked',
{ value: 70, disabled: true })
s.enable(); s.disable()
new Slider(target, options?) — target is a CSS
selector string or an element. A non-matching target throws; constructing on
an element that already holds an instance destroys the old instance first.
Without a DOM (SSR), the constructor returns an inert no-op instance.
Build mode — pass an empty container and the slider is
built inside it. With name set, hidden input(s) are created so
plain form posts work:
<div id="volume"></div>
<script>
var volume = new Slider('#volume', { value: 40, suffix: '%' })
</script>
Dual thumb — a two-element array as value
creates a range with two thumbs that clamp at each other:
new Slider('#price', {
min: 0, max: 1000, step: 10,
value: [200, 650], // [lo, hi] — sorted, snapped, clamped
prefix: '$'
})
Replace mode (progressive enhancement) — pass an
<input> and it is hidden (via the hidden
attribute) and kept synced, so the form keeps posting the real value
("20,80" when dual) and receives native input /
change events. The input's own min,
max, step, value and
disabled attributes seed any omitted options.
destroy() restores the input untouched:
<input type="range" name="volume" min="0" max="100" value="40">
<script>
new Slider(document.querySelector('[name=volume]'))
</script>
Static helpers cover the same ground:
Slider.create('#el', opts) // identical to new Slider('#el', opts)
Slider.get('#el') // existing instance for an element, or null
Slider.autoInit() // init every [data-vsld] — also runs on page load
All keys of Slider.defaults, with their exact defaults.
Unknown option keys are ignored; there are no per-call-only constructor
options beyond these. In replace mode, omitted min /
max / step / value /
disabled are seeded from the replaced input's attributes.
| Option | Type | Default | Description |
|---|---|---|---|
min | number | 0 |
Lower bound. Non-numeric values fall back to 0; if max < min the two are swapped. |
max | number | 100 |
Upper bound. Non-numeric values fall back to 100. |
step | number | 1 |
Snap grid, anchored at min. Values not > 0 fall back to 1. |
value | number | [number, number] | null | null |
Number = single thumb; two-element array = dual-thumb range (sorted, snapped, clamped); null starts at min. |
marks | boolean | object | false |
true = one tick per step, only when the range spans 1–20 steps (otherwise no ticks). An object like { 0: 'Low', 50: 'Mid', 100: 'High' } places labeled ticks at those values; keys outside [min, max] are ignored. |
tooltip | 'drag' | 'always' | false | 'drag' |
'drag' shows the value bubble while dragging or keyboard-focused; 'always' keeps it visible; false renders no bubble at all. |
format | function | null | null |
fn(value) → string used for the tooltip and aria-valuetext. When set, prefix/suffix are ignored. |
prefix | string | '' |
Shorthand formatting when no format is given: prefix + value + suffix. |
suffix | string | '' |
See prefix. |
vertical | boolean | false |
Rail runs bottom → top. ArrowUp still increases the value. |
disabled | boolean | false |
Start disabled: thumbs unfocusable, form control(s) get the disabled attribute so nothing submits. |
name | string | null | null |
Build mode only: creates hidden input(s) — name for a single value, name[] twice for dual (the [] is appended unless already present). Ignored when replacing an input, which carries the value itself. |
theme | 'auto' | 'light' | 'dark' | 'auto' |
'auto' follows the page theme and re-resolves live; 'light'/'dark' pin the instance. |
styles | boolean | true |
false = headless: no stylesheet is injected; you style the .vsld-* markup yourself (see Theming). |
onInput | function | null | null |
fn(value, slider) — called on every move. See Events. |
onChange | function | null | null |
fn(value, slider) — called on release / commit. See Events. |
labels | object | { value: 'Value', min: 'Minimum value', max: 'Maximum value' } |
Thumb aria-label texts, merged key-by-key with the defaults. labels.value labels a single thumb; labels.min / labels.max label the low / high thumb of a dual slider. |
| Signature | Returns | Description |
|---|---|---|
getValue() | number | [number, number] | Current value — a number for single thumb, a fresh two-element array for dual. |
setValue(value, opts?) | this |
Sets the value, snapped to the step grid and clamped. A dual slider accepts [lo, hi] (auto-sorted) or a single number applied to both thumbs. When the value actually changes it fires onInput + onChange and both CustomEvents — pass { silent: true } to suppress all of them. |
enable() | this |
Re-enables interaction: thumbs get tabindex="0", aria-disabled is removed, form control(s) are re-enabled. |
disable() | this |
Disables interaction (cancels any active drag): thumbs get tabindex="-1" and aria-disabled="true"; the replaced/hidden input(s) get disabled so the slider doesn't submit, like a native control. |
destroy() | this |
Removes all built DOM, restores every attribute it touched (un-hides and re-enables a replaced input), removes its classes, and unregisters the instance. |
Useful instance properties: el (root element),
track, thumbs, values,
input (the replaced input, or null in build mode),
opts, dual.
| Static | Type | Description |
|---|---|---|
Slider.defaults | object | The live defaults object (see Options). Mutate before constructing, e.g. Slider.defaults.styles = false for global headless mode. |
Slider.create(target, options?) | function → Slider | Identical to new Slider(target, options). |
Slider.get(target) | function → Slider | null | The instance bound to an element (selector or element; matches either the original target or the built root), or null. |
Slider.autoInit(root?) | function → Slider[] | Initializes every [data-vsld] element under root (default document), skipping elements that already have an instance. A failing element logs an error and doesn't abort the rest. Returns the created instances. |
Slider.salt | string | false | CSS-isolation namespace token, default 'vc1'. Set your own token or false (no salting) before the first instance is created. |
Slider.css | string (getter) | The full embedded stylesheet, rendered with the current salt — a starting point for headless styling. |
Slider.version | string | '1.0.0'. |
Slider.displayName | string | 'Slider' — family metadata used when registering with the VC core. |
Slider.rootClass | string | 'vsld' — the root element's class. |
Slider.themeVars | object | Maps family theme keys to CSS custom properties: { accent: '--vsld-accent', radius: '--vsld-radius', font: '--vsld-font' }. Used by VC.config(). |
Slider.varScopes | array | ['.vsld', '.vsld[data-theme=dark]'] — the (deliberately unsalted) scopes where theme variables are defined; VC.config() writes its overrides there. |
onInput fires on every move (each drag tick, each key press,
and a changed setValue); onChange fires on commit —
pointer release when the value differs from where the drag started, every
key press, and a changed setValue. Both receive
(value, slider), where value is a number or
[lo, hi] for dual.
| Callback | Arguments | When |
|---|---|---|
onInput | (value, slider) |
Every value change: drag movement, each key press, non-silent setValue. |
onChange | (value, slider) |
Commit: pointer release with a changed value, each key press, non-silent setValue. |
The root element also dispatches CustomEvents, so you can delegate without keeping a reference:
| Event | Fires on | Bubbles | Cancelable | event.detail |
|---|---|---|---|---|
slider:input | root element (.vsld) | yes | no | { value, slider } |
slider:change | root element (.vsld) | yes | no | { value, slider } |
In replace mode the hidden original input additionally
receives real, bubbling (non-cancelable) native input and
change events on the same occasions — dispatched before the
matching callback and CustomEvent — so existing listeners and frameworks
keep working.
Any element with a data-vsld attribute is initialized
automatically on DOMContentLoaded (or immediately if the script
loads later), and again by any explicit Slider.autoInit(root?)
call — already-initialized elements are skipped.
<div data-vsld data-min="0" data-max="100" data-value="40" data-suffix="%"></div> <div data-vsld data-value="20,80" data-name="range" data-tooltip="always"></div>
| Attribute | Value |
|---|---|
data-vsld | Marker only (no value) — opts the element into auto-init. |
data-min | Number (empty ignored). |
data-max | Number (empty ignored). |
data-step | Number (empty ignored). |
data-value | Number, or "a,b" for a dual-thumb range. |
data-tooltip | drag | always; false or 0 disables the bubble. |
data-marks | Empty or true = per-step ticks; otherwise a JSON object of {"value": "label"} (invalid JSON falls back to true). |
data-vertical | Boolean — any value except false/0 is true. |
data-disabled | Boolean — same rule. |
data-prefix | String (non-empty). |
data-suffix | String (non-empty). |
data-name | String (non-empty) — hidden form input name(s). |
data-theme | auto | light | dark (non-empty). |
data-styles | Boolean — false/0 = headless. |
format, labels, onInput
and onChange have no declarative form — use the constructor, or
listen for the slider:* CustomEvents.
With theme: 'auto' (the default) the theme is resolved as:
VC core's shared engine when loaded, otherwise
<html data-theme> / data-bs-theme →
.dark / .light class on <html> →
prefers-color-scheme — re-resolved live via a MutationObserver
and a media-query listener. theme: 'light' /
'dark' pins an instance. The resolved theme is stamped on the
root as data-theme.
All colors are CSS custom properties, defined on .vsld and
re-defined on .vsld[data-theme=dark]:
| Property | Light default | Dark default | Used for |
|---|---|---|---|
--vsld-accent | #5b5bd6 | #7b7bea |
Fill bar, thumb ring, focus ring. |
--vsld-bg | #ffffff | #1b1d24 |
Thumb body, tooltip text. |
--vsld-text | #1c1d21 | #e9eaf0 |
Component text, tooltip background. |
--vsld-muted | #72747e | #989aa6 |
Mark dots and mark labels. |
--vsld-faint | #e7e7ec | #31343f |
Track rail. |
--vsld-shadow | 0 1px 4px rgba(24,25,32,.14), 0 1px 2px rgba(24,25,32,.08) |
0 1px 4px rgba(0,0,0,.5), 0 1px 2px rgba(0,0,0,.35) |
Thumb shadow. |
--vsld-radius | 8px | 8px |
Tooltip corner radius. |
--vsld-font | system-ui stack | system-ui stack | Component font family. |
CSS isolation: roots render as
class="vsld vc1" and all structural rules ship salted
(.vsld.vc1 .vsld-thumb { … }), so host-page design systems
can't override the slider — while .vsld { --vsld-* } variable
overrides keep working, because the variable definitions are
deliberately unsalted. The injected stylesheet is inserted before the page's
own CSS so your overrides win the cascade. Custom token:
Slider.salt = 'acme' before the first instance; disable with
Slider.salt = false. With the VC core loaded,
VC.config({ accent: '#b45309' }) themes sliders and every other
family component at once.
Headless: Slider.defaults.styles = false
(or styles: false per instance) injects no CSS while keeping
the full behavior — pointer capture, clamping, keyboard, ARIA, form sync.
The stock stylesheet is available as the Slider.css string as
a starting point. Markup contract:
.vsld.vsld-vertical.vsld-tip-always.vsld-disabled[data-theme=dark]
.vsld-track ← the rail (press/drag surface)
.vsld-fill ← range fill bar
.vsld-mark ← tick; one per step or per `marks` key
.vsld-mark-label ← only for object marks
.vsld-thumb ← role=slider; .vsld-active while dragging
.vsld-tip ← value bubble (absent when tooltip: false)
input[type=hidden] ← only when `name` given
Each thumb is a focusable role="slider" element carrying
aria-valuemin / aria-valuemax /
aria-valuenow, a formatted aria-valuetext, an
aria-label from labels, and
aria-orientation="vertical" when vertical. Dual thumbs
advertise the sibling as their live limit (the low thumb's
aria-valuemax is the high thumb's value, and vice versa).
Disabled thumbs get aria-disabled="true" and
tabindex="-1"; the tooltip bubble is aria-hidden.
Transitions are dropped under prefers-reduced-motion: reduce.
| Key | Action |
|---|---|
ArrowLeft / ArrowDown | Decrease by one step. |
ArrowRight / ArrowUp | Increase by one step (ArrowUp increases even when vertical). |
PageUp | Increase by 10 × step. |
PageDown | Decrease by 10 × step. |
Home | Jump to min (a dual slider's high thumb stops at the low thumb). |
End | Jump to max (a dual slider's low thumb stops at the high thumb). |
Key presses are ignored while disabled or with
Alt/Ctrl/Meta held. Every key press
is both a move and a commit — it fires onInput and
onChange.
styles: false injects nothing — you keep the full behavior
(pointer capture, clamping, form sync), the ARIA slider semantics and
keyboard handling, and the stable .vsld-* class hooks; you
bring your own CSS. Each demo runs in its own <iframe>:
the styled examples above already injected the slider stylesheet into this
page, so only a clean document can show truly unstyled output.
styles: false)The raw markup contract — track, fill and role="slider"
thumb with class hooks, zero CSS injected. Tab to it and use the arrow
keys: the behavior is all there.
new Slider('#s', {
styles: false, // zero CSS injected
min: 0, max: 100, value: 40,
onInput: v => out.value = v
})
The same headless dual slider mapped to a different design — gradient
fill, ring on the active thumb, value tips and marks — loaded on demand
from cdn.tailwindcss.com.
<style type="text/tailwindcss">
.vsld { @apply relative py-6; }
.vsld-track { @apply relative h-2 cursor-pointer
rounded-full bg-slate-200; }
.vsld-fill { @apply absolute inset-y-0 rounded-full
bg-gradient-to-r from-indigo-500 to-fuchsia-500; }
.vsld-thumb { @apply absolute top-1/2 h-5 w-5
-translate-x-1/2 -translate-y-1/2 cursor-grab
rounded-full border-2 border-indigo-500 bg-white
shadow transition-transform; }
.vsld-thumb.vsld-active { @apply scale-125 cursor-grabbing
ring-4 ring-indigo-500/25; }
.vsld-thumb:focus-visible { @apply outline-none ring-4
ring-indigo-500/40; }
.vsld-tip { @apply pointer-events-none absolute bottom-7
left-1/2 -translate-x-1/2 rounded-md bg-slate-900
px-1.5 py-0.5 text-[11px] font-semibold text-white
opacity-0 transition-opacity; }
.vsld-thumb.vsld-active .vsld-tip,
.vsld-thumb:focus-visible .vsld-tip { @apply opacity-100; }
.vsld-mark { @apply absolute top-1/2; }
.vsld-mark-label { @apply absolute left-1/2 top-3
-translate-x-1/2 text-[11px] text-slate-400; }
</style>