View as Markdown

Slider

An input where the user selects a value from within a given range.

100%
Loading...
<twig:Slider name="demo" min="0" max="100" step="1" value="75" class="mx-auto w-full max-w-xs" />

Installation

php bin/console ux:install slider --kit shadcn

Install the following Composer dependencies:

composer require twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Copy the following file(s) into your app:

assets/controllers/slider_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['track', 'range', 'thumb', 'input'];
    static values = {
        min: { type: Number, default: 0 },
        max: { type: Number, default: 100 },
        step: { type: Number, default: 1 },
        orientation: { type: String, default: 'horizontal' },
    };

    connect() {
        this._activeThumb = null;
        this._abortController = new AbortController();
        const { signal } = this._abortController;

        this.thumbTargets.forEach((thumb, index) => {
            thumb.addEventListener('pointerdown', (e) => this._onThumbDown(e, index), { signal });
            thumb.addEventListener('keydown', (e) => this._onThumbKey(e, index), { signal });
        });
        this.trackTarget.addEventListener('pointerdown', (e) => this._onTrackDown(e), { signal });

        this.update();
    }

    disconnect() {
        this._abortController?.abort();
        this._abortController = null;
        this._dragAbortController?.abort();
        this._dragAbortController = null;
        this._activeThumb = null;
    }

    _startDrag() {
        this._dragAbortController?.abort();
        this._dragAbortController = new AbortController();
        const { signal } = this._dragAbortController;
        window.addEventListener('pointermove', (e) => this._onPointerMove(e), { signal });
        window.addEventListener('pointerup', () => this._onPointerUp(), { signal });
    }

    _onThumbDown(event, index) {
        if (this._isDisabled()) return;
        event.preventDefault();
        this._activeThumb = index;
        this.thumbTargets[index].focus();
        this._startDrag();
    }

    _onTrackDown(event) {
        if (this._isDisabled()) return;
        const ratio = this._pointerRatio(event);
        const value = this._ratioToValue(ratio);
        const index = this._nearestThumbIndex(value);
        this._setThumbValue(index, value);
        this._activeThumb = index;
        this.thumbTargets[index].focus();
        this._startDrag();
    }

    _onPointerMove(event) {
        if (this._activeThumb === null) return;
        const ratio = this._pointerRatio(event);
        this._setThumbValue(this._activeThumb, this._ratioToValue(ratio));
    }

    _onPointerUp() {
        this._activeThumb = null;
        this._dragAbortController?.abort();
        this._dragAbortController = null;
    }

    _onThumbKey(event, index) {
        if (this._isDisabled()) return;
        const current = Number(this.inputTargets[index].value);
        const step = this.stepValue;
        let next = current;
        switch (event.key) {
            case 'ArrowLeft':
            case 'ArrowDown':
                next = current - step;
                break;
            case 'ArrowRight':
            case 'ArrowUp':
                next = current + step;
                break;
            case 'Home':
                next = this.minValue;
                break;
            case 'End':
                next = this.maxValue;
                break;
            case 'PageDown':
                next = current - step * 10;
                break;
            case 'PageUp':
                next = current + step * 10;
                break;
            default:
                return;
        }
        event.preventDefault();
        this._setThumbValue(index, next);
    }

    _pointerRatio(event) {
        const rect = this.trackTarget.getBoundingClientRect();
        const isVertical = this.orientationValue === 'vertical';
        const isRtl = getComputedStyle(this.element).direction === 'rtl';
        let ratio;
        if (isVertical) {
            ratio = 1 - (event.clientY - rect.top) / rect.height;
        } else {
            ratio = (event.clientX - rect.left) / rect.width;
            if (isRtl) ratio = 1 - ratio;
        }
        return Math.max(0, Math.min(1, ratio));
    }

    _ratioToValue(ratio) {
        const raw = this.minValue + ratio * (this.maxValue - this.minValue);
        const stepped = Math.round(raw / this.stepValue) * this.stepValue;
        return this._roundToStep(stepped);
    }

    _roundToStep(value) {
        const stepString = String(this.stepValue);
        const decimals = stepString.includes('.') ? stepString.split('.')[1].length : 0;
        return Number(value.toFixed(decimals));
    }

    _nearestThumbIndex(value) {
        const values = this.inputTargets.map((i) => Number(i.value));
        let bestIndex = 0;
        let bestDelta = Math.abs(values[0] - value);
        for (let i = 1; i < values.length; i++) {
            const d = Math.abs(values[i] - value);
            if (d < bestDelta) {
                bestDelta = d;
                bestIndex = i;
            }
        }
        return bestIndex;
    }

    _setThumbValue(index, value) {
        value = this._roundToStep(Math.max(this.minValue, Math.min(this.maxValue, value)));
        const values = this.inputTargets.map((i) => Number(i.value));
        if (index > 0) value = Math.max(value, values[index - 1]);
        if (index < values.length - 1) value = Math.min(value, values[index + 1]);
        if (values[index] === value) return;
        this.inputTargets[index].value = value;
        this.update();
        this.element.dispatchEvent(new Event('input', { bubbles: true }));
        this.element.dispatchEvent(new Event('change', { bubbles: true }));
    }

    update() {
        const values = this.inputTargets.map((i) => Number(i.value));
        const range = this.maxValue - this.minValue;
        const toPercent = (v) => (range > 0 ? ((v - this.minValue) / range) * 100 : 0);
        const percents = values.map(toPercent);

        const isVertical = this.orientationValue === 'vertical';
        const isRtl = getComputedStyle(this.element).direction === 'rtl';

        const prop = isVertical ? 'bottom' : isRtl ? 'right' : 'left';
        this.thumbTargets.forEach((thumb, i) => {
            thumb.style.left = thumb.style.right = thumb.style.bottom = '';
            thumb.style[prop] = `${percents[i]}%`;
            thumb.setAttribute('aria-valuenow', values[i]);
        });

        const lo = percents.length > 1 ? Math.min(...percents) : 0;
        const hi = Math.max(...percents);

        this.rangeTarget.style.top =
            this.rangeTarget.style.bottom =
            this.rangeTarget.style.left =
            this.rangeTarget.style.right =
                '';
        if (isVertical) {
            this.rangeTarget.style.bottom = `${lo}%`;
            this.rangeTarget.style.top = `${100 - hi}%`;
        } else if (isRtl) {
            this.rangeTarget.style.right = `${lo}%`;
            this.rangeTarget.style.left = `${100 - hi}%`;
        } else {
            this.rangeTarget.style.left = `${lo}%`;
            this.rangeTarget.style.right = `${100 - hi}%`;
        }
    }

    _isDisabled() {
        return this.element.getAttribute('aria-disabled') === 'true' || this.element.matches('[data-disabled]');
    }
}
assets/controllers/slider_display_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * @value  separator  The string used to join the thumb values shown in the output.
 * @target output     The element whose text content displays the slider's current values.
 * @action update     Writes the slider's current values into the output target.
 */
export default class extends Controller {
    static targets = ['output'];
    static values = {
        separator: { type: String, default: ', ' },
    };

    update(event) {
        const slider = event.currentTarget;
        const values = Array.from(slider.querySelectorAll('[data-slider-target="input"]')).map((i) => i.value);
        this.outputTarget.textContent = values.join(this.separatorValue);
    }
}
templates/components/Slider.html.twig
{# @prop min int The minimum value. #}
{# @prop max int The maximum value. #}
{# @prop step int The step between two adjacent values. #}
{# @prop value int|string|array A single value, a comma-separated string, or an array of values (one per thumb). #}
{# @prop orientation 'horizontal'|'vertical' The slider orientation. #}
{# @prop disabled bool Whether the slider is disabled. #}
{# @prop name string The form field name. If multiple thumbs, `[]` is appended. #}
{# @prop labels string|array A label per thumb (comma-separated string or array). When omitted, single-thumb sliders fall back to `Slider` and multi-thumb sliders fall back to `Slider thumb X of Y`. #}
{%- props min = 0, max = 100, step = 1, value = null, orientation = 'horizontal', disabled = false, name = null, labels = null -%}
{%- if value is null -%}{%- set value = min -%}{%- endif -%}
{%- set _raw_values = value is iterable ? value : value|split(',') -%}
{%- set values = [] -%}
{%- for v in _raw_values -%}
    {%- set values = values|merge([v * 1]) -%}
{%- endfor -%}
{%- set _range = max - min -%}
{%- set _is_vertical = orientation == 'vertical' -%}
{%- set _percents = [] -%}
{%- for v in values -%}
    {%- set _p = _range > 0 ? ((v - min) / _range * 100) : 0 -%}
    {%- set _percents = _percents|merge([_p]) -%}
{%- endfor -%}
{%- set _lo = values|length > 1 ? min(_percents) : 0 -%}
{%- set _hi = max(_percents) -%}
{%- if labels is null -%}
    {%- set _labels = [] -%}
{%- elseif labels is iterable -%}
    {%- set _labels = labels -%}
{%- else -%}
    {%- set _labels = labels|split(',') -%}
{%- endif -%}
{%- set _classes = 'group/slider relative flex touch-none select-none items-center '
    ~ (_is_vertical ? 'h-40 w-1.5 flex-col justify-center ' : 'w-full h-4 ')
    ~ (disabled ? 'opacity-50 pointer-events-none ' : '') -%}
<div
    data-slot="slider"
    data-orientation="{{ orientation }}"
    data-slider-min-value="{{ min }}"
    data-slider-max-value="{{ max }}"
    data-slider-step-value="{{ step }}"
    data-slider-orientation-value="{{ orientation }}"
    {{ attributes.defaults({
        class: _classes|tailwind_classes,
        'data-controller': 'slider',
    }) }}
>
    <div
        class="relative {{ _is_vertical ? 'h-full w-1.5' : 'h-1.5 w-full' }} grow overflow-hidden rounded-full bg-muted"
        data-slot="slider-track"
        data-slider-target="track"
    >
        <div
            class="absolute {{ _is_vertical ? 'left-0 right-0' : 'top-0 bottom-0' }} bg-primary"
            style="{% if _is_vertical %}bottom: {{ _lo }}%; top: {{ 100 - _hi }}%{% else %}inset-inline-start: {{ _lo }}%; inset-inline-end: {{ 100 - _hi }}%{% endif %};"
            data-slot="slider-range"
            data-slider-target="range"
        ></div>
    </div>
    {% for v in values %}
        {%- set _percent = _percents[loop.index0] -%}
        {%- set _label = _labels[loop.index0] ?? null -%}
        {%- if _label is null -%}
            {%- set _label = values|length > 1 ? 'Slider thumb ' ~ loop.index ~ ' of ' ~ values|length : 'Slider' -%}
        {%- endif -%}
        <span
            role="slider"
            tabindex="{{ disabled ? -1 : 0 }}"
            aria-valuemin="{{ min }}"
            aria-valuemax="{{ max }}"
            aria-valuenow="{{ v }}"
            aria-orientation="{{ orientation }}"
            aria-label="{{ _label }}"
            {% if disabled %}aria-disabled="true"{% endif %}
            class="absolute block size-3.5 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] outline-none hover:ring-4 focus-visible:ring-4 {{ _is_vertical ? '-translate-x-1/2 translate-y-1/2 left-1/2' : '-translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 top-1/2' }}"
            style="{% if _is_vertical %}bottom: {{ _percent }}%{% else %}inset-inline-start: {{ _percent }}%{% endif %};"
            data-slot="slider-thumb"
            data-slider-target="thumb"
        ></span>
    {% endfor %}
    {% for v in values %}
        <input type="hidden"{% if name %} name="{{ values|length > 1 ? name ~ '[]' : name }}"{% endif %} value="{{ v }}" data-slider-target="input">
    {% endfor %}
</div>

Usage

<twig:Slider name="volume" min="0" max="100" value="50" />

Examples

Range

100%
Loading...
<twig:Slider name="range" min="0" max="100" step="5" value="25,50" labels="Min,Max" class="mx-auto w-full max-w-xs" />

Multiple Thumbs

100%
Loading...
<twig:Slider name="multi" min="0" max="100" step="10" value="10,20,70" labels="Low,Medium,High" class="mx-auto w-full max-w-xs" />

Vertical

100%
Loading...
<div class="mx-auto flex w-full max-w-xs items-center justify-center gap-6">
    <twig:Slider name="vertical-1" min="0" max="100" step="1" value="50" orientation="vertical" />
    <twig:Slider name="vertical-2" min="0" max="100" step="1" value="25" orientation="vertical" />
</div>

Disabled

100%
Loading...
<twig:Slider name="disabled" min="0" max="100" step="1" value="50" disabled class="mx-auto w-full max-w-xs" />

Controlled

100%
Loading...
<div class="mx-auto grid w-full max-w-xs gap-3" data-controller="slider-display">
    <div class="flex items-center justify-between gap-2">
        <twig:Label for="slider-controlled-temperature">Temperature</twig:Label>
        <span class="text-sm text-muted-foreground" data-slider-display-target="output">0.3, 0.7</span>
    </div>
    <twig:Slider id="slider-controlled-temperature" name="temperature" min="0" max="1" step="0.1" value="0.3,0.7" data-action="input->slider-display#update" />
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<twig:Slider dir="rtl" name="rtl-slider" min="0" max="100" step="1" value="75" class="mx-auto w-full max-w-xs" />

API Reference

<twig:Slider>

Prop Type Default
min 
int 0
max 
int 100
step 
int 1
value 
int|string|array null
orientation 
'horizontal'|'vertical' 'horizontal'
disabled 
bool false
name 
string null
labels 
string|array null

data-controller="slider-display"

Value Type Default
data-slider-display-separator-value 
String '
Target Description
output The element whose text content displays the slider's current values.
Action Description
update Writes the slider's current values into the output target.