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: true gives [low, high], kept apart by minDistance
  • 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-valuetext and 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.

A single value

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.

Two thumbs

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.

Ticks and marks
<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.

Vertical and right-to-left
<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.

In a form
Budget
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.

In an F.create blueprint
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 silent
  • setRange(min, max) - new bounds; the value is pulled inside them
  • enable(), disable(), isDisabled(), destroy()

Events

  • input(value) - while dragging, and on each key press
  • change(value) - when the value is committed
  • beforechange(next, current) - return false to 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:

Events
  • 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]));