Range Slider
One value or a range between two thumbs - with ticks, marks and a tooltip, full keyboard support, and real range inputs underneath for your forms
Overview
E.rangeSlider() (or E.slider()) draws a slider over real <input type="range"> elements. Point it at a range input and that input stays the element - its min, max, step, value and name are read from it - or point it at a container and the inputs are made for you.
The inputs are hidden but still carry the value, so forms submit it, FormData reads it, and anything listening for the inputs' native input and change events - including Domma's model bindings - hears every move.
Key Features
- One or two thumbs -
range: truegives[low, high], kept apart byminDistance - Ticks, marks and a tooltip - all labelled through one formatter
- Keyboard - arrows step, Page Up / Page Down move ten steps, Home / End go to the ends
- Accessible - ARIA sliders with
aria-valuenow,aria-valuetextand per-thumb limits - Pointer and touch - drag with pointer capture, a large hit area, press anywhere on the track
- Vertical and right-to-left
Basic Usage
// <input id="volume" type="range" name="volume" min="0" max="100" value="40">
const volume = E.rangeSlider('#volume');
volume.on('change', (value) => save(value));
A single value
Drag the thumb, press anywhere on the track, or tab to it and use the arrow keys. onInput runs while it moves, onChange when you let go.
Value: 40
<div class="sx-rs-field mb-2">
<label class="form-label" for="demo-volume">Volume</label>
<input id="demo-volume" type="range" name="volume" min="0" max="100" value="40">
</div>
<p class="mb-4">Value: <span id="demo-volume-value" class="sx-rs-value">40</span></p>/** A single value - a slider over a range input, which keeps its min, max and value. */
export function mount(root) {
const shown = $(root).find('#demo-volume-value');
const volume = E.rangeSlider($(root).find('#demo-volume'), {
onInput: (value) => shown.text(value)
});
return () => volume.destroy();
}E.rangeSlider('#demo-volume', {
onInput: (value) => $('#demo-volume-value').text(value)
});
Two thumbs
range: true makes a low and a high thumb. They never cross, and minDistance keeps them that far apart. labels formats the value everywhere it is shown - the tooltip, the ticks and the aria-valuetext a screen reader speaks.
Between £100 and £600
<div id="demo-price" class="sx-rs-field mb-2"></div>
<p class="mb-4">Between <span id="demo-price-value" class="sx-rs-value">£100 and £600</span></p>/** Two thumbs - a price range, kept at least £50 apart. */
export function mount(root) {
const shown = $(root).find('#demo-price-value');
const price = E.rangeSlider($(root).find('#demo-price'), {
range: true,
min: 0,
max: 1000,
step: 10,
value: [100, 600],
minDistance: 50,
name: 'price',
ariaLabel: 'Price',
labels: (v) => '£' + v,
tooltip: 'always',
onInput: ([low, high]) => shown.text('£' + low + ' and £' + high)
});
return () => price.destroy();
}const price = E.rangeSlider('#demo-price', {
range: true,
min: 0,
max: 1000,
step: 10,
value: [100, 600],
minDistance: 50,
name: 'price',
ariaLabel: 'Price',
labels: (v) => '£' + v,
tooltip: 'always'
});
price.getValue(); // [100, 600]
price.setValue([200, 400]);
Ticks and marks
ticks: true draws one at every step, a number draws one every that many units, and an array draws them where you say (and labels them). marks are labelled points; pressing one jumps straight to it.
<div class="sx-rs-field mb-4">
<label class="form-label" for="demo-rating">Rating</label>
<input id="demo-rating" type="range" min="1" max="5" value="3">
</div>
<div class="sx-rs-field mb-4">
<label class="form-label" for="demo-temp">Wash temperature</label>
<input id="demo-temp" type="range" min="20" max="90" step="5" value="40">
</div>/** Ticks and marks - a tick at every step, and labelled marks to jump to. */
export function mount(root) {
const rating = E.rangeSlider($(root).find('#demo-rating'), {
ticks: true,
tickLabels: true,
labels: (v) => v + '★'
});
const temp = E.rangeSlider($(root).find('#demo-temp'), {
ticks: 10,
labels: (v) => v + '°C',
marks: [
{value: 30, label: 'Delicates'},
{value: 40, label: 'Everyday'},
{value: 60, label: 'Towels'},
{value: 90, label: 'Boil'}
]
});
return () => {
rating.destroy();
temp.destroy();
};
}E.rangeSlider('#demo-rating', {ticks: true, tickLabels: true, labels: (v) => v + '★'});
E.rangeSlider('#demo-temp', {
ticks: 10,
labels: (v) => v + '°C',
marks: [
{value: 30, label: 'Delicates'},
{value: 40, label: 'Everyday'},
{value: 60, label: 'Towels'},
{value: 90, label: 'Boil'}
]
});
Vertical and right-to-left
orientation: 'vertical' stands it up (minimum at the bottom). In a right-to-left page - or with rtl: true - the minimum is on the right and the arrow keys follow it, as a native range input does.
<div class="sx-rs-verticals mb-4">
<div id="demo-eq-1"></div>
<div id="demo-eq-2"></div>
<div id="demo-eq-3"></div>
</div>
<div class="sx-rs-field mb-4" dir="rtl" lang="ar">
<label class="form-label" for="demo-rtl">مستوى الصوت</label>
<input id="demo-rtl" type="range" min="0" max="10" value="3">
</div>/** Vertical and right-to-left - a three-band equaliser, and a slider in an Arabic block. */
export function mount(root) {
const bands = [['#demo-eq-1', 'Bass', 3], ['#demo-eq-2', 'Middle', -2], ['#demo-eq-3', 'Treble', 6]];
const sliders = bands.map(([selector, name, value]) => E.rangeSlider($(root).find(selector), {
orientation: 'vertical',
min: -12,
max: 12,
value,
ariaLabel: name,
labels: (v) => (v > 0 ? '+' : '') + v + ' dB'
}));
// The block is dir="rtl", so the minimum is on the right
sliders.push(E.rangeSlider($(root).find('#demo-rtl'), {ticks: true}));
return () => sliders.forEach(s => s.destroy());
}E.rangeSlider('#demo-eq-1', {orientation: 'vertical', min: -12, max: 12, value: 3, ariaLabel: 'Bass'});
E.rangeSlider('#demo-rtl', {ticks: true}); // the page's direction is followed
In a form
A two-thumb slider submits two inputs. They share the name by default, so the form sends budget=200&budget=800 (FormData.getAll('budget') gives both); names: ['budget_min', 'budget_max'] names them separately. Reset puts the slider back with the form.
Nothing submitted yet
<form id="demo-form" class="sx-rs-field mb-4">
<div class="mb-4">
<label class="form-label" for="demo-form-guests">Guests</label>
<input id="demo-form-guests" type="range" name="guests" min="1" max="12" value="2">
</div>
<div class="mb-4">
<span class="form-label">Budget</span>
<div id="demo-form-budget"></div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">Show FormData</button>
<button class="btn btn-outline" type="reset">Reset</button>
</div>
</form>
<pre id="demo-form-output" class="sx-rs-output mb-4">Nothing submitted yet</pre>/** In a form - the sliders' hidden range inputs submit with it, and Reset puts them back. */
export function mount(root) {
const form = $(root).find('#demo-form');
const output = $(root).find('#demo-form-output');
const guests = E.rangeSlider($(root).find('#demo-form-guests'), {ticks: true});
const budget = E.rangeSlider($(root).find('#demo-form-budget'), {
range: true,
min: 0,
max: 2000,
step: 50,
value: [200, 800],
names: ['budget_min', 'budget_max'],
ariaLabels: ['Budget from', 'Budget to'],
labels: (v) => '£' + v
});
// Show what the form would send instead of sending it
const show = (e) => {
e.preventDefault();
const lines = [];
for (const [key, value] of new FormData(e.currentTarget).entries()) lines.push(key + ' = ' + value);
output.text(lines.join('\n'));
};
form.on('submit', show);
return () => {
form.off('submit', show);
guests.destroy();
budget.destroy();
};
}E.rangeSlider('#demo-form-guests', {ticks: true});
E.rangeSlider('#demo-form-budget', {
range: true, min: 0, max: 2000, step: 50, value: [200, 800],
names: ['budget_min', 'budget_max'], ariaLabels: ['Budget from', 'Budget to'],
labels: (v) => '£' + v
});
In an F.create blueprint
type: 'slider' is the slider as a form field; formConfig takes its options. With formConfig.range the field's value is [low, high]. A plain type: 'range' stays a native input unless formConfig.slider is set.
Submit to see the data
<div id="demo-blueprint" class="sx-rs-field mb-3"></div>
<pre id="demo-blueprint-output" class="sx-rs-output mb-4">Submit to see the data</pre>/** In an F.create blueprint - slider fields, one of them a [low, high] pair. */
export function mount(root) {
const output = $(root).find('#demo-blueprint-output');
const form = F.create({
brightness: {
type: 'slider', label: 'Brightness', min: 0, max: 100, default: 70,
formConfig: {labels: (v) => v + '%'}
},
hours: {
type: 'slider', label: 'Opening hours', min: 0, max: 24, default: [9, 17],
formConfig: {
range: true, minDistance: 1, ticks: 6, tickLabels: true,
labels: (v) => String(v).padStart(2, '0') + ':00'
}
}
}, {}, {
submitText: 'Save',
onSubmit: (data) => output.text(JSON.stringify(data))
});
const target = $(root).find('#demo-blueprint');
form.renderTo(target.get(0));
return () => {
form.destroy();
target.empty();
};
}const form = F.create({
brightness: {type: 'slider', label: 'Brightness', min: 0, max: 100, default: 70,
formConfig: {labels: (v) => v + '%'}},
hours: {type: 'slider', label: 'Opening hours', min: 0, max: 24, default: [9, 17],
formConfig: {range: true, minDistance: 1, ticks: 6, tickLabels: true,
labels: (v) => String(v).padStart(2, '0') + ':00'}}
}, {}, {onSubmit: (data) => console.log(data.hours)}); // [9, 17]
form.renderTo('#demo-blueprint');
form.destroy(); // removes the sliders with the form
API
Methods
getValue()- a number, or[low, high]setValue(value, {silent})- fires beforechange, input and change unless silentsetRange(min, max)- new bounds; the value is pulled inside themenable(),disable(),isDisabled(),destroy()
Events
input(value)- while dragging, and on each key presschange(value)- when the value is committedbeforechange(next, current)- returnfalseto refuse a move
Each is an onX option, a .on('x') listener and a bubbling dm:range-slider:x DOM event. This one refuses to go above 80, and logs its change events from the DOM:
- Nothing yet - move the slider
<div class="sx-rs-field mb-2">
<label class="form-label" for="demo-capped">Capped at 80</label>
<input id="demo-capped" type="range" value="50">
</div>
<div class="flex gap-2 mb-4">
<button id="demo-capped-disable" class="btn btn-outline btn-sm" type="button">Disable</button>
<button id="demo-capped-set" class="btn btn-ghost btn-sm" type="button">setValue(25)</button>
</div>
<ul id="demo-log" class="sx-rs-log" aria-live="off">
<li class="sx-rs-empty">Nothing yet - move the slider</li>
</ul>/** Events - beforechange refuses anything above 80; change is logged from the DOM event. */
export function mount(root) {
const list = $(root).find('#demo-log');
const log = (message) => {
list.find('.sx-rs-empty').remove();
list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
list.find('li').filter((i) => i >= 8).remove();
};
const capped = E.rangeSlider($(root).find('#demo-capped'), {
labels: (v) => v + ' / 100',
onBeforeChange: (next) => next <= 80
});
// Every event also bubbles as dm:range-slider:<event>, with {instance, args} in detail
const onChange = (e) => log('change: ' + JSON.stringify(e.detail.args[0]));
$(root).on('dm:range-slider:change', onChange);
const disable = $(root).find('#demo-capped-disable');
const set = $(root).find('#demo-capped-set');
const flip = () => {
if (capped.isDisabled()) capped.enable();
else capped.disable();
disable.text(capped.isDisabled() ? 'Enable' : 'Disable');
};
const setTo25 = () => capped.setValue(25);
disable.on('click', flip);
set.on('click', setTo25);
return () => {
$(root).off('dm:range-slider:change', onChange);
disable.off('click', flip);
set.off('click', setTo25);
capped.destroy();
};
}const capped = E.rangeSlider('#demo-capped', {
labels: (v) => v + ' / 100',
onBeforeChange: (next) => next <= 80
});
// detail = {instance, args}
$('#demo-capped').on('dm:range-slider:change', (e) => log(e.detail.args[0]));