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 The minimum value.
|
int |
0 |
max The maximum value.
|
int |
100 |
step The step between two adjacent values.
|
int |
1 |
value A single value, a comma-separated string, or an array of values (one per thumb).
|
int|string|array |
null |
orientation The slider orientation.
|
'horizontal'|'vertical' |
'horizontal' |
disabled Whether the slider is disabled.
|
bool |
false |
name The form field name. If multiple thumbs,
[] is appended. |
string |
null |
labels 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. |
string|array |
null |
data-controller="slider-display"
| Value | Type | Default |
|---|---|---|
data-slider-display-separator-value The string used to join the thumb values shown in the output.
|
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. |