Slider Rails Components
Sliders let people pick a value or a range by dragging. One small Stimulus controller handles pointer, touch and keyboard input, ARIA, and form fields, while every pixel of the look stays in your Tailwind classes.
Installation
Slider Controller
Add the controller to app/javascript/controllers/slider_controller.js. It has no dependencies. It publishes the thumb position as CSS variables, so the markup decides whether a slider is thin, thick, horizontal or vertical.
import { Controller } from "@hotwired/stimulus";
// Slider and range slider.
//
// The controller owns the values, pointer + keyboard input, ARIA and form
// inputs. It never touches layout: it only publishes CSS custom properties
// that your Tailwind classes read, so the same controller drives thin, thick,
// horizontal, vertical and two-thumb sliders.
//
// --slider-position on each thumb where that thumb sits (0% - 100%)
// --slider-start on the root where the filled range starts
// --slider-end on the root where the filled range ends
// --slider-stretch on the root rubber-band amount while over-dragging
//
// Render the first three in your HTML too (style="--slider-position: 40%"),
// so the slider looks right before JavaScript loads.
//
// State attributes for styling:
//
// data-dragging on the root while a pointer is down on the track
// data-jumping on the root for 200ms after a press on the track, so
// the thumb can glide to the pointer
// data-disabled on the root while the slider is disabled
// data-at-min on the root while the first thumb is at min
// data-at-max on the root while the last thumb is at max
// data-active on a thumb while that thumb is pressed
// data-focus-visible on a thumb while it has keyboard focus (browsers
// show :focus-visible after a scripted
// focus, so the controller tracks it)
// data-active on a tick while the tick is inside the range
// data-overshoot on the root "min" or "max" while over-dragging
//
// Optional "field" targets are text inputs bound to one thumb (data-index).
// They show the formatted value, switch to a plain number while focused, and
// commit on Enter or blur. Arrow Up / Down step the value (Shift = large step).
//
// The track gets touch-action and user-select from the controller when your
// classes don't set them, so touch drags never scroll the page.
export default class extends Controller {
static targets = ["track", "thumb", "input", "output", "tick", "field"];
static values = {
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
step: { type: Number, default: 1 },
largeStep: { type: Number, default: 0 }, // Page Up/Down and Shift + arrows. 0 = 10% of the range
minDistance: { type: Number, default: 0 }, // Smallest gap between two thumbs
orientation: { type: String, default: "horizontal" }, // "horizontal" or "vertical"
disabled: { type: Boolean, default: false },
rubberBand: { type: Boolean, default: false }, // Stretch the track when dragged past its ends
format: { type: Object, default: {} }, // Intl.NumberFormat options, e.g. { style: "currency", currency: "USD" }
labels: { type: Array, default: [] }, // Optional names for each step, e.g. ["Low", "Medium", "High"]
valueText: { type: String, default: "" }, // Screen reader text, e.g. "{value} decibels"
};
initialize() {
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.endDrag = this.endDrag.bind(this);
this.onKeydown = this.onKeydown.bind(this);
this.onFormReset = this.onFormReset.bind(this);
this.onFocusIn = this.onFocusIn.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
this.onFieldFocus = this.onFieldFocus.bind(this);
this.onFieldKeydown = this.onFieldKeydown.bind(this);
this.onFieldBlur = this.onFieldBlur.bind(this);
this.scheduleSync = this.scheduleSync.bind(this);
}
connect() {
this.root = this.hasTrackTarget ? this.trackTarget : this.element;
this.drag = null;
// The starting value of each thumb comes from its input, or from
// aria-valuenow when there is no input.
this.current = this.readValues();
this.initial = this.readInitial();
this.root.addEventListener("pointerdown", this.onPointerDown);
this.element.addEventListener("keydown", this.onKeydown);
this.element.addEventListener("focusin", this.onFocusIn);
this.element.addEventListener("focusout", this.onFocusOut);
// A Turbo morph refresh keeps this element (so connect() does not run
// again) but resets its attributes and inputs to the server's HTML.
this.element.addEventListener("turbo:morph-element", this.scheduleSync);
this.form = this.element.closest("form") || this.inputTargets[0]?.form || null;
this.form?.addEventListener("reset", this.onFormReset);
this.connected = true;
this.applyInteractionStyles();
this.render();
}
disconnect() {
this.connected = false;
this.endDrag();
clearTimeout(this.jumpTimer);
this.element.removeAttribute("data-jumping");
this.root.removeEventListener("pointerdown", this.onPointerDown);
this.element.removeEventListener("keydown", this.onKeydown);
this.element.removeEventListener("focusin", this.onFocusIn);
this.element.removeEventListener("focusout", this.onFocusOut);
this.element.removeEventListener("turbo:morph-element", this.scheduleSync);
this.form?.removeEventListener("reset", this.onFormReset);
}
// ---- Changes after connect ----------------------------------------------
// Value attributes can change at any time (by script or a morph). Stimulus
// also calls these before connect, which the `connected` guard skips.
minValueChanged() {
this.refresh();
}
maxValueChanged() {
this.refresh();
}
stepValueChanged() {
this.refresh();
}
minDistanceValueChanged() {
this.refresh();
}
labelsValueChanged() {
this.refresh();
}
valueTextValueChanged() {
this.refresh();
}
orientationValueChanged() {
if (!this.connected) return;
this.applyInteractionStyles();
this.render();
}
disabledValueChanged() {
if (!this.connected) return;
if (this.disabledValue) this.endDrag();
this.render();
}
formatValueChanged() {
this.formatter = null;
this.refresh();
}
// Thumbs and inputs added or removed later (a morph, a template) re-read
// the values from the markup.
thumbTargetConnected() {
this.scheduleSync();
}
thumbTargetDisconnected() {
this.scheduleSync();
}
inputTargetConnected() {
this.scheduleSync();
}
inputTargetDisconnected() {
this.scheduleSync();
}
fieldTargetConnected(field) {
field.addEventListener("focus", this.onFieldFocus);
field.addEventListener("keydown", this.onFieldKeydown);
field.addEventListener("blur", this.onFieldBlur);
if (this.connected) this.render();
}
fieldTargetDisconnected(field) {
field.removeEventListener("focus", this.onFieldFocus);
field.removeEventListener("keydown", this.onFieldKeydown);
field.removeEventListener("blur", this.onFieldBlur);
}
// Keeps the current values (clamped and snapped to the new settings).
refresh() {
this.editFormatter = null; // The step sets how many decimals a field shows
if (!this.connected) return;
this.current = this.normalize(this.current);
this.render();
}
// Many morph events and target callbacks arrive together: sync once.
scheduleSync() {
if (!this.connected || this.syncQueued) return;
this.syncQueued = true;
queueMicrotask(() => {
this.syncQueued = false;
if (this.connected) this.sync();
});
}
// Re-reads the values from the inputs (the server's values after a morph).
// Mid-drag, the person dragging wins and only the styles are restored.
sync() {
if (!this.drag) this.current = this.readValues();
this.initial = this.readInitial();
this.applyInteractionStyles();
this.render();
}
// ---- Actions ------------------------------------------------------------
// data-action="click->slider#focus" on a visible label focuses the thumb.
focus() {
if (!this.disabledValue && this.thumbTargets.length) this.focusThumb(0);
}
// data-action="click->slider#increment" / "click->slider#decrement"
// Optional params: data-slider-index-param="1", data-slider-amount-param="10"
increment(event) {
this.nudge(event, 1);
}
decrement(event) {
this.nudge(event, -1);
}
// data-action="click->slider#toggle" sets the value to min (mute), and a
// second click brings back the value from before (or the middle of the range).
// The button gets aria-pressed="true" while the value is at min.
toggle(event) {
if (this.disabledValue) return;
const index = this.indexParam(event);
const value = this.current[index];
let next;
if (value > this.minValue) {
this.restoreValues ||= {};
this.restoreValues[index] = value;
next = this.minValue;
} else {
next = this.restoreValues?.[index] ?? (this.minValue + this.maxValue) / 2;
}
if (this.setValue(index, next)) this.commit(index);
}
// data-action="click->slider#set" data-slider-value-param="50"
set(event) {
if (this.disabledValue) return;
const index = this.indexParam(event);
if (this.setValue(index, Number(event.params.value))) this.commit(index);
}
// ---- Pointer ------------------------------------------------------------
onPointerDown(event) {
if (this.disabledValue || this.drag || event.button !== 0 || !this.thumbTargets.length) return;
const fraction = this.fractionAt(event);
const grabbed = this.thumbTargets.find((thumb) => thumb.contains(event.target));
const index = grabbed ? this.thumbTargets.indexOf(grabbed) : this.nearestThumb(this.valueAt(fraction));
// Stops text selection and keeps focus management in our hands.
event.preventDefault();
this.drag = {
pointerId: event.pointerId,
index,
// Grabbing a thumb off-centre should not make it jump under the pointer.
offset: grabbed ? fraction - this.fractionOf(this.current[index]) : 0,
startValues: [...this.current],
};
try {
this.root.setPointerCapture(event.pointerId);
} catch {
// Synthetic events have no active pointer to capture. Dragging still works.
}
// Listen on window, not the slider: if capture is lost, a release outside
// the slider (or outside the browser) must still end the drag.
window.addEventListener("pointermove", this.onPointerMove);
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointercancel", this.onPointerUp);
window.addEventListener("blur", this.endDrag);
this.root.addEventListener("lostpointercapture", this.endDrag);
this.activate(index);
this.element.setAttribute("data-dragging", "");
// Pointer capture makes the browser use the root's cursor, not the thumb's,
// so pin the grabbing cursor on the page for the length of the drag.
this.drag.cursor = document.documentElement.style.cursor;
document.documentElement.style.cursor = "grabbing";
// The capturing track usually sets its own cursor, which beats the inherited one.
this.drag.rootCursor = this.root.style.cursor;
this.root.style.cursor = "grabbing";
if (!grabbed) {
// A press on the track glides the nearest thumb to the pointer. Position
// transitions only exist while data-jumping is set, so dragging tracks
// the pointer 1:1 and keyboard steps land instantly.
if (!this.reducedMotion) {
this.element.setAttribute("data-jumping", "");
clearTimeout(this.jumpTimer);
this.jumpTimer = setTimeout(() => this.element.removeAttribute("data-jumping"), 200);
}
this.setValue(index, this.valueAt(fraction));
}
}
onPointerMove(event) {
if (!this.drag || event.pointerId !== this.drag.pointerId) return;
// The button was released somewhere we never heard about (another window,
// the desktop, browser chrome). Stop instead of dragging with no button held.
if (event.pointerType === "mouse" && event.buttons === 0) {
this.endDrag();
return;
}
const fraction = this.fractionAt(event) - this.drag.offset;
const target = this.valueAt(fraction);
// When two thumbs sit on the same value, hand the drag to whichever one
// can actually travel in the direction of the pointer.
let index = this.drag.index;
const value = this.current[index];
while (target < value && index > 0 && this.current[index - 1] === value) index--;
while (target > value && index < this.current.length - 1 && this.current[index + 1] === value) index++;
if (index !== this.drag.index) this.activate(index);
this.setValue(index, target);
if (this.rubberBandValue) this.stretch(fraction);
}
onPointerUp(event) {
if (!this.drag || event.pointerId !== this.drag.pointerId) return;
this.endDrag();
}
endDrag() {
if (!this.drag) return;
const { pointerId, index, startValues, cursor, rootCursor } = this.drag;
this.drag = null;
document.documentElement.style.cursor = cursor ?? "";
this.root.style.cursor = rootCursor ?? "";
window.removeEventListener("pointermove", this.onPointerMove);
window.removeEventListener("pointerup", this.onPointerUp);
window.removeEventListener("pointercancel", this.onPointerUp);
window.removeEventListener("blur", this.endDrag);
this.root.removeEventListener("lostpointercapture", this.endDrag);
if (this.root.hasPointerCapture?.(pointerId)) this.root.releasePointerCapture(pointerId);
this.element.removeAttribute("data-dragging");
this.thumbTargets.forEach((thumb) => thumb.removeAttribute("data-active"));
// data-overshoot stays so the spring back keeps its transform origin.
this.element.style.setProperty("--slider-stretch", "0");
if (startValues.some((value, i) => value !== this.current[i])) this.commit(index);
}
// Escape during a drag puts every thumb back where the drag started.
cancelDrag() {
const { startValues } = this.drag;
const changed = startValues.map((_, i) => i).filter((i) => startValues[i] !== this.current[i]);
this.current = [...startValues];
this.render();
changed.forEach((i) => this.announceInput(i));
this.endDrag(); // Nothing changed since the drag started, so no change event
}
activate(index) {
if (this.drag) this.drag.index = index;
this.thumbTargets.forEach((thumb, i) => thumb.toggleAttribute("data-active", i === index));
this.focusThumb(index);
}
// Focus that comes from a pointer should not draw the keyboard focus ring.
focusThumb(index) {
const thumb = this.thumbTargets[index];
this.pointerFocus = true;
thumb.focus({ preventScroll: true });
this.pointerFocus = false;
thumb.removeAttribute("data-focus-visible");
}
onFocusIn(event) {
if (this.thumbTargets.includes(event.target)) {
event.target.toggleAttribute("data-focus-visible", !this.pointerFocus);
}
}
onFocusOut(event) {
if (this.thumbTargets.includes(event.target)) event.target.removeAttribute("data-focus-visible");
}
// Rubber band: the further past the end you pull, the less the track gives.
stretch(fraction) {
const over = fraction < 0 ? fraction : fraction > 1 ? fraction - 1 : 0;
if (over === 0 || this.reducedMotion) {
this.element.style.setProperty("--slider-stretch", "0");
return;
}
const rail = this.rail();
const pixels = Math.abs(over) * (this.vertical ? rail.height : rail.width);
const amount = 0.08 * (1 - 1 / (pixels / 110 + 1));
this.element.dataset.overshoot = over < 0 ? "min" : "max";
this.element.style.setProperty("--slider-stretch", amount.toFixed(4));
}
// ---- Keyboard -----------------------------------------------------------
onKeydown(event) {
if (event.key === "Escape" && this.drag) {
event.preventDefault();
this.cancelDrag();
return;
}
if (this.disabledValue || event.altKey || event.ctrlKey || event.metaKey) return;
const index = this.thumbTargets.findIndex((thumb) => thumb === event.target);
if (index === -1) return;
const value = this.current[index];
const step = event.shiftKey ? this.bigStep : this.stepValue;
const forward = this.rtl && !this.vertical ? -1 : 1;
let next;
switch (event.key) {
case "ArrowRight":
next = value + step * forward;
break;
case "ArrowLeft":
next = value - step * forward;
break;
case "ArrowUp":
next = value + step;
break;
case "ArrowDown":
next = value - step;
break;
case "PageUp":
next = value + this.bigStep;
break;
case "PageDown":
next = value - this.bigStep;
break;
case "Home":
next = this.minValue;
break;
case "End":
next = this.maxValue;
break;
default:
return;
}
event.preventDefault();
event.target.setAttribute("data-focus-visible", "");
if (this.setValue(index, next)) this.commit(index);
}
// ---- Fields -------------------------------------------------------------
onFieldFocus(event) {
const field = event.target;
field.value = this.editText(this.current[this.fieldIndex(field)]);
field.select();
}
onFieldKeydown(event) {
const field = event.target;
const index = this.fieldIndex(field);
switch (event.key) {
case "ArrowUp":
case "ArrowDown": {
event.preventDefault();
this.commitField(field);
const direction = event.key === "ArrowUp" ? 1 : -1;
// Same steps as the thumb: Shift moves by the large step.
const step = event.shiftKey ? this.bigStep : this.stepValue;
if (this.setValue(index, this.current[index] + direction * step)) this.commit(index);
field.value = this.editText(this.current[index]);
field.select();
break;
}
case "Enter":
event.preventDefault();
this.commitField(field);
field.value = this.editText(this.current[index]);
field.select();
break;
case "Escape":
event.preventDefault();
field.value = this.editText(this.current[index]); // Throw away the typed text
field.blur();
break;
}
}
onFieldBlur(event) {
this.commitField(event.target);
this.render();
}
// Typed text moves the thumb: clamped, snapped and kept apart from the other
// thumb by setValue. Text that is not a number changes nothing.
commitField(field) {
const index = this.fieldIndex(field);
const typed = this.parseNumber(field.value);
if (Number.isFinite(typed) && this.setValue(index, typed)) this.commit(index);
}
fieldIndex(field) {
const index = Number(field.dataset.index);
return Number.isInteger(index) ? index : 0;
}
// Reads a number typed in the page's locale: "1,200.5" in English and
// "1.200,5" in German. Accepts the minus sign (−) and Arabic-Indic digits.
parseNumber(text) {
const { decimal, group } = this.separators;
let cleaned = String(text)
.trim()
.replace(/[٠-٩]/g, (digit) => digit.charCodeAt(0) - 0x0660)
.replace(/[۰-۹]/g, (digit) => digit.charCodeAt(0) - 0x06f0)
.replace(/[−–]/g, "-")
.replace(/\s/g, "");
if (group) cleaned = cleaned.split(group).join("");
if (decimal !== ".") cleaned = cleaned.split(decimal).join(".");
return Number.parseFloat(cleaned.replace(/[^\d.-]/g, ""));
}
onFormReset() {
this.endDrag();
this.current = this.normalize(this.initial);
this.render();
}
// ---- Values -------------------------------------------------------------
readValues() {
return this.normalize(
this.thumbTargets.map((thumb, index) => {
const raw = this.inputTargets[index]?.value ?? thumb.getAttribute("aria-valuenow");
return raw === null || raw === "" ? this.minValue : Number(raw);
}),
);
}
// Values for form reset. Setting a hidden input's value rewrites its value
// attribute, and Turbo caches the page with it, so the starting values are
// kept in data-slider-initial. A morph removes that attribute, and the
// server's new values become the starting values.
readInitial() {
try {
const saved = JSON.parse(this.element.getAttribute("data-slider-initial"));
if (Array.isArray(saved) && saved.length === this.current.length && saved.every(Number.isFinite)) return saved;
} catch {
// Missing or edited by hand: start over from the current values.
}
this.element.setAttribute("data-slider-initial", JSON.stringify(this.current));
return [...this.current];
}
normalize(values) {
return values
.map((value) => (Number.isFinite(value) ? value : this.minValue))
.map((value) => this.clamp(this.snap(value), this.minValue, this.maxValue))
.sort((a, b) => a - b);
}
// Snaps to the step, keeps the thumb between its neighbours, and re-renders.
// Returns true when the value actually changed.
setValue(index, value) {
if (!Number.isFinite(value) || index < 0 || index >= this.current.length) return false;
const low = index > 0 ? this.current[index - 1] + this.minDistanceValue : this.minValue;
const high = index < this.current.length - 1 ? this.current[index + 1] - this.minDistanceValue : this.maxValue;
const next = this.clamp(this.snap(value), low, high);
if (next === this.current[index]) return false;
this.current[index] = next;
this.render();
this.announceInput(index);
return true;
}
// "slider:input" fires continuously, like the native input event.
announceInput(index) {
this.inputTargets[index]?.dispatchEvent(new Event("input", { bubbles: true }));
this.dispatch("input", { detail: this.detail(index) });
}
// "slider:change" fires once a drag ends or a key changes the value.
commit(index) {
this.inputTargets[index]?.dispatchEvent(new Event("change", { bubbles: true }));
this.dispatch("change", { detail: this.detail(index) });
}
nudge(event, direction) {
if (this.disabledValue) return;
const index = this.indexParam(event);
const amount = Number(event.params.amount) || this.stepValue;
if (this.setValue(index, this.current[index] + amount * direction)) this.commit(index);
}
indexParam(event) {
const index = Number(event.params.index);
return Number.isInteger(index) ? index : this.current.length - 1;
}
detail(index) {
return { index, value: this.current[index], values: [...this.current] };
}
// ---- Rendering ----------------------------------------------------------
// Touch drags must not scroll the page, and a long press must not select
// text or open the iOS callout. Your own touch-action classes win.
applyInteractionStyles() {
const style = this.root.style;
if (this.ownsTouchAction) style.touchAction = "";
this.ownsTouchAction = getComputedStyle(this.root).touchAction === "auto";
if (this.ownsTouchAction) style.touchAction = this.vertical ? "pan-x" : "pan-y";
// Safari will not let you type into inputs inside user-select: none.
if (!this.fieldTargets.some((field) => this.root.contains(field))) {
style.setProperty("user-select", "none");
style.setProperty("-webkit-user-select", "none");
style.setProperty("-webkit-touch-callout", "none");
}
}
render() {
const last = this.current.length - 1;
this.thumbTargets.forEach((thumb, index) => {
const value = this.current[index];
thumb.style.setProperty("--slider-position", `${this.fractionOf(value) * 100}%`);
thumb.setAttribute("role", "slider");
thumb.setAttribute("tabindex", this.disabledValue ? "-1" : "0");
thumb.setAttribute("aria-orientation", this.vertical ? "vertical" : "horizontal");
// Each thumb of a range is bounded by its neighbour.
thumb.setAttribute("aria-valuemin", index > 0 ? this.current[index - 1] + this.minDistanceValue : this.minValue);
thumb.setAttribute("aria-valuemax", index < last ? this.current[index + 1] - this.minDistanceValue : this.maxValue);
thumb.setAttribute("aria-valuenow", value);
thumb.setAttribute("aria-valuetext", this.valueText(value));
if (this.disabledValue) thumb.setAttribute("aria-disabled", "true");
else thumb.removeAttribute("aria-disabled");
const input = this.inputTargets[index];
if (input) {
input.value = value;
input.disabled = this.disabledValue;
}
});
// One thumb fills from the start. Two thumbs fill the gap between them.
const start = last > 0 ? this.fractionOf(this.current[0]) : 0;
const end = last >= 0 ? this.fractionOf(this.current[last]) : 0;
this.element.style.setProperty("--slider-start", `${start * 100}%`);
this.element.style.setProperty("--slider-end", `${end * 100}%`);
this.element.toggleAttribute("data-disabled", this.disabledValue);
this.element.toggleAttribute("data-at-min", last >= 0 && this.current[0] <= this.minValue);
this.element.toggleAttribute("data-at-max", last >= 0 && this.current[last] >= this.maxValue);
// Outputs show one thumb's value when they live inside that thumb (a
// tooltip) or carry data-index, and the whole range otherwise.
this.outputTargets.forEach((output) => {
const thumbIndex = this.thumbTargets.findIndex((thumb) => thumb.contains(output));
const index = thumbIndex !== -1 ? thumbIndex : output.dataset.index;
const text =
index === undefined
? this.current.map((value) => this.format(value)).join(" – ")
: this.format(this.current[Number(index)]);
if (output.textContent !== text) output.textContent = text;
});
// Fields show the formatted value, except the one being edited.
this.fieldTargets.forEach((field) => {
const index = this.fieldIndex(field);
field.disabled = this.disabledValue;
if (document.activeElement === field) {
if (this.parseNumber(field.value) !== this.current[index]) field.value = this.editText(this.current[index]);
} else {
field.value = this.format(this.current[index]);
}
});
const from = last > 0 ? this.current[0] : this.minValue;
const to = last >= 0 ? this.current[last] : this.minValue;
this.tickTargets.forEach((tick) => {
const value = Number(tick.dataset.value);
tick.toggleAttribute("data-active", value >= from && value <= to);
});
// Mute buttons report whether they are pressed (the value is at min).
this.element.querySelectorAll("[data-action]").forEach((button) => {
if (!/(^|[\s>])slider#toggle\b/.test(button.getAttribute("data-action"))) return;
const param = Number.parseInt(button.getAttribute("data-slider-index-param"), 10);
const value = this.current[Number.isInteger(param) ? param : last];
if (value !== undefined) button.setAttribute("aria-pressed", String(value <= this.minValue));
});
}
format(value) {
if (this.labelsValue.length) {
const label = this.labelsValue[Math.round((value - this.minValue) / this.stepValue)];
if (label !== undefined) return String(label);
}
if (!this.formatter) {
try {
this.formatter = new Intl.NumberFormat(this.locale, this.formatValue);
} catch {
this.formatter = new Intl.NumberFormat(this.locale);
}
}
return this.formatter.format(value);
}
// aria-valuetext: the formatted value, or the valueText template with
// {value} replaced, e.g. "{value} decibels".
valueText(value) {
const text = this.format(value);
return this.valueTextValue ? this.valueTextValue.replaceAll("{value}", text) : text;
}
// The number shown while a field is focused: no grouping, local decimals.
editText(value) {
this.editFormatter ||= new Intl.NumberFormat(this.locale, {
useGrouping: false,
maximumFractionDigits: Math.min(this.precision, 20),
});
return this.editFormatter.format(value);
}
get separators() {
if (!this.cachedSeparators) {
const parts = new Intl.NumberFormat(this.locale).formatToParts(12345.6);
this.cachedSeparators = {
decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
group: parts.find((part) => part.type === "group")?.value.replace(/\s/g, "") ?? "",
};
}
return this.cachedSeparators;
}
get locale() {
return document.documentElement.lang || undefined;
}
// ---- Geometry -----------------------------------------------------------
// The thumb travels inside the track's padding box, so padding on the track
// (half a thumb wide) keeps the thumb from overhanging the ends.
rail() {
const rect = this.root.getBoundingClientRect();
const style = getComputedStyle(this.root);
const left = parseFloat(style.paddingLeft) || 0;
const right = parseFloat(style.paddingRight) || 0;
const top = parseFloat(style.paddingTop) || 0;
const bottom = parseFloat(style.paddingBottom) || 0;
return {
left: rect.left + left,
top: rect.top + top,
width: Math.max(rect.width - left - right, 1),
height: Math.max(rect.height - top - bottom, 1),
};
}
// Pointer position as a 0-1 fraction of the rail. Not clamped, so the
// rubber band knows how far past the end the pointer is.
fractionAt(event) {
const rail = this.rail();
if (this.vertical) return 1 - (event.clientY - rail.top) / rail.height;
const fraction = (event.clientX - rail.left) / rail.width;
return this.rtl ? 1 - fraction : fraction;
}
fractionOf(value) {
const span = this.maxValue - this.minValue;
return span > 0 ? (value - this.minValue) / span : 0;
}
valueAt(fraction) {
return this.minValue + this.clamp(fraction, 0, 1) * (this.maxValue - this.minValue);
}
nearestThumb(value) {
let nearest = 0;
this.current.forEach((current, index) => {
const distance = Math.abs(current - value);
const best = Math.abs(this.current[nearest] - value);
// On a tie, prefer the thumb that can move towards the pointer.
if (distance < best || (distance === best && value > current)) nearest = index;
});
return nearest;
}
snap(value) {
const step = this.stepValue > 0 ? this.stepValue : 1;
const snapped = this.minValue + Math.round((value - this.minValue) / step) * step;
// Trim floating point noise (0.1 + 0.2) to the precision of the step.
return Number(Math.min(snapped, this.maxValue).toFixed(this.precision));
}
clamp(value, low, high) {
return Math.min(Math.max(value, low), Math.max(low, high));
}
get precision() {
const decimals = (number) => (String(number).split(".")[1] || "").length;
return Math.max(decimals(this.stepValue), decimals(this.minValue));
}
get bigStep() {
const step = this.stepValue > 0 ? this.stepValue : 1;
const large = this.largeStepValue || (this.maxValue - this.minValue) / 10;
return Math.max(step, Math.round(large / step) * step);
}
get vertical() {
return this.orientationValue === "vertical";
}
get rtl() {
return getComputedStyle(this.root).direction === "rtl";
}
get reducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
}
Examples
Basic Slider
A single-value slider with a live readout. Drag the thumb, press anywhere on the track, or focus it and use the arrow keys, Page Up / Page Down, Home and End. Press Escape during a drag to put the thumb back. The --slider-* styles in the HTML match the starting value, so the slider is drawn in place before JavaScript loads.
<%# A unique id, so the label still works when this slider is rendered more than once %>
<% label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<%# The --slider-* styles match the starting value, so the slider is drawn in place before JavaScript loads %>
<div class="group/slider w-full max-w-xs" data-controller="slider" data-slider-min-value="0" data-slider-max-value="100" style="--slider-end: 40%">
<div class="mb-2 flex items-baseline justify-between">
<span id="<%= label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100" data-action="click->slider#focus">Volume</span>
<output data-slider-target="output" class="text-sm tabular-nums text-neutral-500 dark:text-neutral-400">40</output>
</div>
<!-- The horizontal padding (half a thumb) keeps the thumb inside the track at 0% and 100% -->
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<!-- Track -->
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<!-- Fill -->
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<!-- Thumb -->
<div data-slider-target="thumb" aria-labelledby="<%= label_id %>" style="--slider-position: 40%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<!-- Holds the starting value and is submitted with the form -->
<input type="hidden" name="volume" value="40" data-slider-target="input">
</div>
Slider with Steps
A few named steps. The thumb snaps instantly from step to step, the readout shows the step's name via the labels value, and the labels under the track are clickable shortcuts.
<% qualities = ["Draft", "Good", "High", "Ultra", "Lossless"] %>
<% value = 2 %>
<% position = value * 100.0 / (qualities.size - 1) %>
<% label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<div class="group/slider w-full max-w-xs" data-controller="slider" data-slider-min-value="0" data-slider-max-value="<%= qualities.size - 1 %>" data-slider-step-value="1" data-slider-labels-value="<%= qualities.to_json %>" style="--slider-end: <%= position %>%">
<div class="mb-2 flex items-baseline justify-between">
<span id="<%= label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100" data-action="click->slider#focus">Export quality</span>
<output data-slider-target="output" class="text-sm text-neutral-500 dark:text-neutral-400"><%= qualities[value] %></output>
</div>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-labelledby="<%= label_id %>" style="--slider-position: <%= position %>%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 motion-reduce:transition-none forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<!-- Step labels: click one to jump straight to it. The first and last line up with the ends of the track, so they never hang over the edge. Below 16rem only those two show -->
<div class="@container/marks relative mx-2.5 mt-2 h-5" aria-hidden="true">
<% qualities.each_with_index do |quality, index| %>
<button type="button" tabindex="-1" data-slider-target="tick" data-value="<%= index %>"<%= " data-active" if index <= value %> data-action="click->slider#set" data-slider-value-param="<%= index %>" class="absolute top-0 <%= index.zero? ? "" : index == qualities.size - 1 ? "-translate-x-full rtl:translate-x-full" : "hidden @3xs/marks:block -translate-x-1/2 rtl:translate-x-1/2" %> cursor-pointer rounded-sm px-1 text-xs text-neutral-400 hover:text-neutral-900 data-[active]:text-neutral-700 dark:text-neutral-500 dark:hover:text-white dark:data-[active]:text-neutral-200" style="inset-inline-start: <%= index * 100.0 / (qualities.size - 1) %>%"><%= quality %></button>
<% end %>
</div>
<input type="hidden" name="export[quality]" value="<%= value %>" data-slider-target="input">
</div>
Range Slider
Two thumbs make a price filter. Values are formatted as currency with Intl.NumberFormat, the thumbs keep a minimum distance, and the histogram bars inside the selected range light up. Minimum and Maximum are editable field targets: type a price and press Enter, or use the arrow keys (Shift for a large step, like the thumbs). The fields read numbers in the page language, so 1.200,5 works on a German page.
<%# Listings per $50 price bucket, from $0 to $1,200 %>
<% histogram = [2, 5, 9, 14, 22, 31, 38, 44, 41, 36, 30, 27, 21, 18, 13, 11, 8, 6, 5, 3, 3, 2, 1, 1] %>
<% low, high, ceiling = 250, 700, 1200 %>
<% label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<div role="group" aria-labelledby="<%= label_id %>" class="group/slider w-full max-w-sm rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900" data-controller="slider" data-slider-min-value="0" data-slider-max-value="<%= ceiling %>" data-slider-step-value="50" data-slider-min-distance-value="50" data-slider-format-value='{"style":"currency","currency":"USD","maximumFractionDigits":0}' style="--slider-start: <%= (low * 100.0 / ceiling).round(2) %>%; --slider-end: <%= (high * 100.0 / ceiling).round(2) %>%">
<div class="flex items-baseline justify-between">
<span id="<%= label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Price per night</span>
<output data-slider-target="output" class="text-sm tabular-nums text-neutral-500 dark:text-neutral-400"><%= number_to_currency(low, precision: 0) %> – <%= number_to_currency(high, precision: 0) %></output>
</div>
<!-- Histogram: each bar is a tick, so bars inside the selected range light up -->
<div class="mt-5 flex h-14 items-end gap-0.5 px-2.5" aria-hidden="true">
<% histogram.each_with_index do |count, index| %>
<span data-slider-target="tick" data-value="<%= index * 50 + 25 %>"<%= " data-active" if (low..high).cover?(index * 50 + 25) %> class="flex-1 rounded-t-xs bg-neutral-200 data-[active]:bg-neutral-800 dark:bg-neutral-700 dark:data-[active]:bg-neutral-100 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText] forced-colors:data-[active]:bg-[Highlight]" style="height: <%= (count * 100.0 / histogram.max).round %>%"></span>
<% end %>
</div>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0 -mt-2.5">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<!-- Fill: with two thumbs it spans from --slider-start to --slider-end -->
<div class="absolute start-[var(--slider-start)] end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Minimum price" style="--slider-position: <%= (low * 100.0 / ceiling).round(2) %>%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
<div data-slider-target="thumb" aria-label="Maximum price" style="--slider-position: <%= (high * 100.0 / ceiling).round(2) %>%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<!-- Editable fields: type a price, or use Arrow Up / Down (Shift = large step). 16px text on touch screens, so iOS does not zoom in on focus -->
<div class="mt-4 grid grid-cols-2 gap-3">
<label class="block cursor-text rounded-lg border border-black/10 px-3 py-2 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-neutral-600 dark:border-white/10 dark:focus-within:outline-neutral-200">
<span class="block text-xs text-neutral-500 dark:text-neutral-400">Minimum</span>
<input type="text" inputmode="numeric" autocomplete="off" data-slider-target="field" data-index="0" value="<%= number_to_currency(low, precision: 0) %>" aria-label="Minimum price" class="w-full bg-transparent p-0 text-base font-medium pointer-fine:text-sm text-neutral-900 tabular-nums outline-none dark:text-neutral-100">
</label>
<label class="block cursor-text rounded-lg border border-black/10 px-3 py-2 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-neutral-600 dark:border-white/10 dark:focus-within:outline-neutral-200">
<span class="block text-xs text-neutral-500 dark:text-neutral-400">Maximum</span>
<input type="text" inputmode="numeric" autocomplete="off" data-slider-target="field" data-index="1" value="<%= number_to_currency(high, precision: 0) %>" aria-label="Maximum price" class="w-full bg-transparent p-0 text-base font-medium pointer-fine:text-sm text-neutral-900 tabular-nums outline-none dark:text-neutral-100">
</label>
</div>
<!-- One input per thumb, in the same order -->
<input type="hidden" name="price[min]" value="<%= low %>" data-slider-target="input">
<input type="hidden" name="price[max]" value="<%= high %>" data-slider-target="input">
</div>
Slider with Tooltip
The value rides above the thumb and updates as you drag. With a step of 0.5, the format always shows one decimal (10.0%), and tabular-nums plus a min-width sized for the longest value keep the bubble from changing width.
<% label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<div class="group/slider w-full max-w-xs" data-controller="slider" data-slider-min-value="0" data-slider-max-value="30" data-slider-step-value="0.5" data-slider-format-value='{"style":"unit","unit":"percent","minimumFractionDigits":1,"maximumFractionDigits":1}' style="--slider-end: 41.6667%">
<!-- The bottom margin leaves room for the tooltip -->
<span id="<%= label_id %>" class="mb-10 block text-sm font-medium text-neutral-900 dark:text-neutral-100" data-action="click->slider#focus">Affiliate commission</span>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<!-- The thumb is a wrapper here, so the tooltip rides along without scaling with the knob -->
<div data-slider-target="thumb" aria-labelledby="<%= label_id %>" style="--slider-position: 41.6667%" class="group/thumb absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full outline-0 outline-offset-2 outline-neutral-600 data-[active]:cursor-grabbing data-[focus-visible]:outline-2 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] rtl:translate-x-1/2 dark:outline-neutral-200 forced-colors:outline-[CanvasText]">
<!-- Knob -->
<span class="block size-full rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText]"></span>
<!-- Tooltip: an output inside a thumb shows that thumb's value. The format always shows one decimal (the step is 0.5), tabular-nums keeps digits the same width, and min-w fits the longest value ("30.0%") so the bubble never resizes. -->
<span class="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-md bg-neutral-900 px-1.5 py-1 text-center text-xs font-medium whitespace-nowrap text-white tabular-nums shadow-sm dark:bg-white dark:text-neutral-900">
<span data-slider-target="output" class="block min-w-[5.5ch]">12.5%</span>
<span class="absolute top-full left-1/2 -mt-1 size-2 -translate-x-1/2 rotate-45 rounded-[1px] bg-neutral-900 dark:bg-white"></span>
</span>
</div>
</div>
</div>
<input type="hidden" name="affiliate[commission]" value="12.5" data-slider-target="input">
</div>
Slider with Icons
Volume and brightness rows with icon buttons at both ends. The buttons call the decrement and increment actions to nudge the value by 10. Each button dims at its end of the range, styled from the data-at-min and data-at-max attributes.
<%# The buttons dim at the ends of the range: the controller sets data-at-min and data-at-max on the slider %>
<div class="w-full max-w-sm space-y-1 rounded-xl border border-black/10 bg-white p-3 shadow-xs dark:border-white/10 dark:bg-neutral-900">
<!-- Volume -->
<div class="group/slider flex items-center gap-2" data-controller="slider" style="--slider-end: 60%">
<button type="button" aria-label="Lower volume" data-action="click->slider#decrement" data-slider-amount-param="10" class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-transparent text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200 group-data-[at-min]/slider:cursor-not-allowed group-data-[at-min]/slider:bg-transparent group-data-[at-min]/slider:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="size-[18px]" viewBox="0 0 18 18" aria-hidden="true"><g transform="translate(9 9) scale(1.3) translate(-6.3 -9)" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path vector-effect="non-scaling-stroke" d="M4 6.75H2.5a1 1 0 0 0-1 1v2.5a1 1 0 0 0 1 1H4l3.53 2.82c.33.26.72.03.72-.39V4.32c0-.42-.39-.65-.72-.39L4 6.75Z"></path><path vector-effect="non-scaling-stroke" d="M10.52 7.23a2.5 2.5 0 0 1 0 3.54"></path></g></svg>
</button>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0 flex-1">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Volume" style="--slider-position: 60%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<button type="button" aria-label="Raise volume" data-action="click->slider#increment" data-slider-amount-param="10" class="flex size-8 select-none shrink-0 cursor-pointer items-center justify-center rounded-lg bg-transparent text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200 group-data-[at-max]/slider:cursor-not-allowed group-data-[at-max]/slider:bg-transparent group-data-[at-max]/slider:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="size-[18px]" viewBox="0 0 18 18" aria-hidden="true"><g transform="translate(9 9) scale(1.3) translate(-7.5 -9)" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path vector-effect="non-scaling-stroke" d="M4 6.75H2.5a1 1 0 0 0-1 1v2.5a1 1 0 0 0 1 1H4l3.53 2.82c.33.26.72.03.72-.39V4.32c0-.42-.39-.65-.72-.39L4 6.75Z"></path><path vector-effect="non-scaling-stroke" d="M10.52 7.23a2.5 2.5 0 0 1 0 3.54"></path><path vector-effect="non-scaling-stroke" d="M12.29 5.46a5 5 0 0 1 0 7.07"></path></g></svg>
</button>
<input type="hidden" name="display[volume]" value="60" data-slider-target="input">
</div>
<!-- Brightness -->
<div class="group/slider flex items-center gap-2" data-controller="slider" style="--slider-end: 35%">
<button type="button" aria-label="Lower brightness" data-action="click->slider#decrement" data-slider-amount-param="10" class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-transparent text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200 group-data-[at-min]/slider:cursor-not-allowed group-data-[at-min]/slider:bg-transparent group-data-[at-min]/slider:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="size-[18px]" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><line x1="9" y1="1.5" x2="9" y2="1.75"></line><line x1="14.303" y1="3.697" x2="14.127" y2="3.873"></line><line x1="16.5" y1="9" x2="16.25" y2="9"></line><line x1="14.303" y1="14.303" x2="14.127" y2="14.127"></line><line x1="9" y1="16.5" x2="9" y2="16.25"></line><line x1="3.697" y1="14.303" x2="3.873" y2="14.127"></line><line x1="1.5" y1="9" x2="1.75" y2="9"></line><line x1="3.697" y1="3.697" x2="3.873" y2="3.873"></line><circle cx="9" cy="9" r="4.25"></circle></g></svg>
</button>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0 flex-1">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Brightness" style="--slider-position: 35%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<button type="button" aria-label="Raise brightness" data-action="click->slider#increment" data-slider-amount-param="10" class="flex size-8 select-none shrink-0 cursor-pointer items-center justify-center rounded-lg bg-transparent text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200 group-data-[at-max]/slider:cursor-not-allowed group-data-[at-max]/slider:bg-transparent group-data-[at-max]/slider:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="size-[18px]" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><line x1="9" y1=".75" x2="9" y2="2.25"></line><line x1="14.834" y1="3.166" x2="13.773" y2="4.227"></line><line x1="17.25" y1="9" x2="15.75" y2="9"></line><line x1="14.834" y1="14.834" x2="13.773" y2="13.773"></line><line x1="9" y1="17.25" x2="9" y2="15.75"></line><line x1="3.166" y1="14.834" x2="4.227" y2="13.773"></line><line x1=".75" y1="9" x2="2.25" y2="9"></line><line x1="3.166" y1="3.166" x2="4.227" y2="4.227"></line><circle cx="9" cy="9" r="4.25"></circle></g></svg>
</button>
<input type="hidden" name="display[brightness]" value="35" data-slider-target="input">
</div>
</div>
Slider with Dynamic Icon
The speaker gains a wave at 1, 34 and 67, and shows a mute cross at 0. The waves are plain tick targets, so CSS swaps them with a 100ms fade and no extra JavaScript. Click the icon to mute; click again to bring the previous volume back with the toggle action. The controller keeps aria-pressed on the button in sync, also when you drag the volume to 0.
<!-- The waves are tick targets: each one is data-active while the volume is at or above its data-value -->
<div class="group/slider flex w-full max-w-xs items-center gap-2" data-controller="slider" style="--slider-end: 45%">
<!-- Click to mute; click again to bring the previous volume back. The controller keeps aria-pressed in sync, also when you drag to 0 -->
<button type="button" aria-label="Mute" aria-pressed="false" data-action="click->slider#toggle" class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-transparent text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
<svg xmlns="http://www.w3.org/2000/svg" class="size-[18px]" viewBox="0 0 18 18" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path d="M4 6.75H2.5a1 1 0 0 0-1 1v2.5a1 1 0 0 0 1 1H4l3.53 2.82c.33.26.72.03.72-.39V4.32c0-.42-.39-.65-.72-.39L4 6.75Z"></path>
<!-- Waves share one centre with the speaker, so it never shifts as they appear: 1–33 one wave, 34–66 two, 67–100 three -->
<path data-slider-target="tick" data-value="1" data-active class="opacity-0 transition-opacity duration-100 ease-out data-[active]:opacity-100" d="M10.52 7.23a2.5 2.5 0 0 1 0 3.54"></path>
<path data-slider-target="tick" data-value="34" data-active class="opacity-0 transition-opacity duration-100 ease-out data-[active]:opacity-100" d="M12.29 5.46a5 5 0 0 1 0 7.07"></path>
<path data-slider-target="tick" data-value="67" class="opacity-0 transition-opacity duration-100 ease-out data-[active]:opacity-100" d="M14.05 3.7a7.5 7.5 0 0 1 0 10.6"></path>
<!-- Muted: shows while no wave is active -->
<path class="transition-opacity duration-100 ease-out group-has-[path[data-active]]/slider:opacity-0" d="M11.25 7.25l3.5 3.5M14.75 7.25l-3.5 3.5"></path>
</g>
</svg>
</button>
<div data-slider-target="track" class="relative h-5 flex-1 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Volume" style="--slider-position: 45%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<output data-slider-target="output" class="w-8 shrink-0 text-end text-sm tabular-nums text-neutral-500 dark:text-neutral-400">45</output>
<input type="hidden" name="volume" value="45" data-slider-target="input">
</div>
Fluid Slider
A thick, iOS-style slider where the fill is the handle and the label inverts as the fill passes under it. Drag past either end and the track stretches like a rubber band, then springs back on release.
<% controls = [
{ label: "Brightness", name: "display[brightness]", value: 70 },
{ label: "Volume", name: "display[volume]", value: 35 }
] %>
<div class="w-full max-w-xs space-y-3">
<% controls.each do |control| %>
<!-- Drag past either end and the track stretches like a rubber band -->
<div class="group/slider" data-controller="slider" data-slider-rubber-band-value="true" data-slider-format-value='{"style":"unit","unit":"percent"}' style="--slider-end: <%= control[:value] %>%">
<div data-slider-target="track" class="relative h-11 cursor-grab touch-pan-y overflow-hidden rounded-xl bg-neutral-200/70 px-3 [transform:scaleX(calc(1+var(--slider-stretch,0)))_scaleY(calc(1-var(--slider-stretch,0)*0.6))] transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] outline-offset-2 outline-neutral-600 has-[[data-focus-visible]]:outline-2 group-data-[dragging]/slider:cursor-grabbing group-data-[dragging]/slider:transition-none group-data-[overshoot=max]/slider:origin-left group-data-[overshoot=min]/slider:origin-right motion-reduce:transition-none dark:bg-neutral-800 dark:outline-neutral-200 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText] forced-colors:outline-[CanvasText]">
<div class="relative h-full">
<!-- Fill: the track is the handle -->
<div class="absolute inset-y-0 -start-3 end-[calc(100%-var(--slider-end)-0.75rem)] bg-neutral-900 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] dark:bg-white forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<!-- Thumb: a grip line that shows up on hover, focus and drag -->
<div data-slider-target="thumb" aria-label="<%= control[:label] %>" style="--slider-position: <%= control[:value] %>%" class="absolute inset-y-0 start-[var(--slider-position)] flex w-6 -translate-x-1/2 items-center justify-center outline-none group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] rtl:translate-x-1/2">
<span class="h-4 w-0.5 rounded-full bg-white opacity-0 transition-[opacity,height] duration-200 ease-out group-hover/slider:opacity-50 group-has-[[data-focus-visible]]/slider:opacity-50 group-data-[dragging]/slider:h-5 group-data-[dragging]/slider:opacity-80 dark:bg-neutral-900"></span>
</div>
</div>
<!-- Label and value sit on top and invert over the fill -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-between px-4 text-sm font-medium text-white mix-blend-difference">
<span><%= control[:label] %></span>
<output data-slider-target="output" class="tabular-nums"><%= control[:value] %>%</output>
</div>
</div>
<input type="hidden" name="<%= control[:name] %>" value="<%= control[:value] %>" data-slider-target="input">
</div>
<% end %>
</div>
Scrubby Slider
A number field that is also a slider, like the property inputs in Figma. Drag a field left or right to scrub: the field fills from its left edge to the pointer while the number stays put, and the standard resize cursor shows that the field can be dragged. Hold Shift for 10x steps or Option (Alt on Windows and Linux) for 0.1x. Click to type a number, press Enter to commit or Escape to cancel, and use the arrow keys to nudge. Each field is a real input, and the preview shape reads the values from CSS variables.
This code is available to Pro users only.
<!-- Hey curious person! -->
<!-- You seem to be sneaking around the code... -->
<!-- I hope you're enjoying the components! -->
<!-- Have a great day! -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secret Message from Rails Blocks</title>
<style>
.secret-message {
font-family: 'Comic Sans MS', cursive;
text-align: center;
padding: 2rem;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.wiggle { animation: wiggle 0.5s ease-in-out infinite; }
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(1deg); }
75% { transform: rotate(-1deg); }
}
</style>
</head>
<body>
<div class="secret-message">
<h1>🎉 Hello, Code Detective! 🕵️</h1>
<p>Thanks for checking out Rails Blocks!</p>
<p>You're clearly someone who pays attention to details.</p>
<p>That's exactly the kind of developer we love!</p>
<div class="cta-section">
<button class="awesome-btn wiggle">You're awesome!</button>
<p><small>Seriously, keep being curious! 🚀</small></p>
</div>
<footer>
<p>Built with ❤️ by the Rails Blocks team</p>
<p>Now go build something amazing!</p>
</footer>
</div>
<script>
console.log("🎊 Bonus points for opening the console!");
console.log("Keep exploring and happy coding! 💻");
</script>
</body>
</html>
This code is available to Pro users only.
// Hey curious person!
// You seem to be sneaking around the code...
// I hope you're enjoying the components!
// Have a great day!
class SecretMessage {
constructor() {
this.message = "Thanks for checking out Rails Blocks!";
this.compliment = "You're awesome!";
}
reveal() {
console.log(this.message);
return this.compliment;
}
}
const secret = new SecretMessage();
secret.reveal();
Vertical Slider
An equalizer built from vertical sliders with negative minimums and signed values. Reset is a plain type="reset" button: sliders inside a form restore their starting values when the form resets. data-slider-value-text-value="{value} decibels" makes screen readers say "+6 decibels" instead of "+6".
<% bands = [
{ label: "60", unit: "Hz", value: 6 },
{ label: "250", unit: "Hz", value: 3 },
{ label: "1k", unit: "kHz", value: -4 },
{ label: "4k", unit: "kHz", value: 2 },
{ label: "16k", unit: "kHz", value: 7 }
] %>
<%# The sliders restore their starting values when the form is reset. Screen readers hear "+6 decibels" through data-slider-value-text-value %>
<form class="w-full max-w-xs rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Equalizer</span>
<button type="reset" class="flex items-center justify-center gap-1.5 rounded-lg bg-transparent px-2 py-1 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">Reset</button>
</div>
<div class="mt-4 flex justify-between">
<% bands.each do |band| %>
<div class="group/slider flex w-10 flex-col items-center gap-2" data-controller="slider" data-slider-orientation-value="vertical" data-slider-min-value="-12" data-slider-max-value="12" data-slider-format-value='{"signDisplay":"exceptZero"}' data-slider-value-text-value="{value} decibels" style="--slider-end: <%= ((band[:value] + 12) * 100.0 / 24).round(2) %>%">
<output data-slider-target="output" class="text-xs tabular-nums text-neutral-500 dark:text-neutral-400"><%= band[:value].zero? ? 0 : format("%+d", band[:value]) %></output>
<!-- Vertical: the padding moves to the top and bottom, and the thumb is placed from the bottom -->
<div data-slider-target="track" class="relative h-36 w-5 cursor-pointer touch-pan-x py-2.5 before:absolute before:-inset-x-3 before:inset-y-0">
<div class="relative h-full">
<div class="absolute -inset-y-2.5 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<!-- 0 dB line: wider than the track so it reads at a glance -->
<span class="absolute top-1/2 left-1/2 h-0.5 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-neutral-400 dark:bg-neutral-500"></span>
<!-- Fill: square ends, flat on the 0 dB line; the thumb covers the other end -->
<div class="absolute top-[min(50%,calc(100%-var(--slider-end)))] bottom-[min(50%,var(--slider-end))] left-1/2 w-1.5 -translate-x-1/2 bg-neutral-800 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] dark:bg-neutral-100 forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="<%= band[:label] %> <%= band[:unit] %> gain" style="--slider-position: <%= ((band[:value] + 12) * 100.0 / 24).round(2) %>%" class="absolute bottom-[var(--slider-position)] left-1/2 size-5 -translate-x-1/2 translate-y-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<span class="text-xs text-neutral-500 dark:text-neutral-400"><%= band[:label] %></span>
<input type="hidden" name="equalizer[<%= band[:label] %>]" value="<%= band[:value] %>" data-slider-target="input">
</div>
<% end %>
</div>
</form>
Slider Sizes
Small, medium and large. Size is only classes: the thumb size, the track height, and a horizontal padding of half a thumb.
<div class="w-full max-w-xs space-y-6">
<!-- Small: 16px thumb, 4px track, px-2 -->
<div class="group/slider" data-controller="slider" style="--slider-end: 30%">
<div data-slider-target="track" class="relative h-4 cursor-pointer touch-pan-y px-2 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2 top-1/2 h-1 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2 end-[calc(100%-var(--slider-end))] top-1/2 h-1 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Small slider" style="--slider-position: 30%" class="absolute start-[var(--slider-position)] top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<input type="hidden" name="small" value="30" data-slider-target="input">
</div>
<!-- Medium: 20px thumb, 6px track, px-2.5 -->
<div class="group/slider" data-controller="slider" style="--slider-end: 50%">
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Medium slider" style="--slider-position: 50%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<input type="hidden" name="medium" value="50" data-slider-target="input">
</div>
<!-- Large: 24px thumb, 8px track, px-3 -->
<div class="group/slider" data-controller="slider" style="--slider-end: 70%">
<div data-slider-target="track" class="relative h-6 cursor-pointer touch-pan-y px-3 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-3 top-1/2 h-2 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-3 end-[calc(100%-var(--slider-end))] top-1/2 h-2 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-label="Large slider" style="--slider-position: 70%" class="absolute start-[var(--slider-position)] top-1/2 size-6 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<input type="hidden" name="large" value="70" data-slider-target="input">
</div>
</div>
Disabled Slider
A disabled slider ignores pointer and keyboard input, leaves the tab order, and its input is not submitted. The disabled look reads data-disabled, so turning data-slider-disabled-value on or off later updates the styles too. The hint below is linked with aria-describedby.
<% label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<% hint_id = "slider_hint_#{SecureRandom.hex(4)}" %>
<%# data-slider-disabled-value="true" blocks pointer and keyboard input, removes the thumb from the tab order and disables the input.
The controller sets data-disabled on the root, and the disabled look reads it, so toggling the value later updates the styles too.
data-disabled is also in the HTML, so the slider looks disabled before JavaScript loads. %>
<div class="group/slider w-full max-w-xs" data-controller="slider" data-slider-max-value="50" data-slider-disabled-value="true" data-slider-format-value='{"style":"unit","unit":"gigabyte"}' data-disabled style="--slider-end: 20%">
<div class="mb-2 flex items-baseline justify-between">
<span id="<%= label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Storage limit</span>
<output data-slider-target="output" class="text-sm tabular-nums text-neutral-500 dark:text-neutral-400">10 GB</output>
</div>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0 group-data-[disabled]/slider:cursor-not-allowed group-data-[disabled]/slider:opacity-50">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight] forced-colors:group-data-[disabled]/slider:bg-[GrayText]"></div>
<div data-slider-target="thumb" aria-labelledby="<%= label_id %>" aria-describedby="<%= hint_id %>" style="--slider-position: 20%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] outline-0 outline-offset-2 outline-neutral-600 data-[active]:cursor-grabbing data-[focus-visible]:outline-2 group-data-[disabled]/slider:cursor-not-allowed group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] dark:outline-neutral-200 forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText] forced-colors:group-data-[disabled]/slider:bg-[GrayText]"></div>
</div>
</div>
<p id="<%= hint_id %>" class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">Upgrade to Pro to raise your storage limit.</p>
<input type="hidden" name="storage_limit" value="10" data-slider-target="input">
</div>
Slider in a Form
Each thumb writes to a real <input>, so sliders submit like any other field. Save sends a GET request inside a Turbo Frame and the server renders the submitted values back.
<%# A real GET form. The hidden inputs submit like any other field, and the Turbo Frame swaps the form in place. %>
<% budget = params.dig(:campaign, :budget).presence&.to_i&.clamp(100, 2000) || 600 %>
<% alert_at = params.dig(:campaign, :alert_at).presence&.to_i&.clamp(50, 100) || 80 %>
<% budget_label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<% alert_label_id = "slider_label_#{SecureRandom.hex(4)}" %>
<%= turbo_frame_tag "slider_form_example", class: "block w-full max-w-sm" do %>
<%= form_with url: request.path, method: :get, class: "rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900" do %>
<div class="space-y-6 p-5">
<div class="group/slider" data-controller="slider" data-slider-min-value="100" data-slider-max-value="2000" data-slider-step-value="50" data-slider-format-value='{"style":"currency","currency":"USD","maximumFractionDigits":0}' style="--slider-end: <%= ((budget - 100) * 100.0 / 1900).round(2) %>%">
<div class="mb-2 flex items-baseline justify-between">
<span id="<%= budget_label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100" data-action="click->slider#focus">Monthly budget</span>
<output data-slider-target="output" class="text-sm tabular-nums text-neutral-500 dark:text-neutral-400"><%= number_to_currency(budget, precision: 0) %></output>
</div>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-labelledby="<%= budget_label_id %>" style="--slider-position: <%= ((budget - 100) * 100.0 / 1900).round(2) %>%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<input type="hidden" name="campaign[budget]" value="<%= budget %>" data-slider-target="input">
</div>
<div class="group/slider" data-controller="slider" data-slider-min-value="50" data-slider-max-value="100" data-slider-step-value="5" data-slider-format-value='{"style":"unit","unit":"percent"}' style="--slider-end: <%= ((alert_at - 50) * 100.0 / 50).round(2) %>%">
<div class="mb-2 flex items-baseline justify-between">
<span id="<%= alert_label_id %>" class="text-sm font-medium text-neutral-900 dark:text-neutral-100" data-action="click->slider#focus">Alert me at</span>
<output data-slider-target="output" class="text-sm tabular-nums text-neutral-500 dark:text-neutral-400"><%= alert_at %>%</output>
</div>
<div data-slider-target="track" class="relative h-5 cursor-pointer touch-pan-y px-2.5 before:absolute before:-inset-y-3 before:inset-x-0">
<div class="relative h-full">
<div class="absolute -inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-neutral-200 dark:bg-neutral-700 forced-colors:forced-color-adjust-none forced-colors:bg-[GrayText]"></div>
<div class="absolute -start-2.5 end-[calc(100%-var(--slider-end))] top-1/2 h-1.5 -translate-y-1/2 rounded-s-full bg-neutral-800 dark:bg-neutral-100 group-data-[jumping]/slider:[transition:inset_200ms_cubic-bezier(0.23,1,0.32,1)] forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>
<div data-slider-target="thumb" aria-labelledby="<%= alert_label_id %>" style="--slider-position: <%= ((alert_at - 50) * 100.0 / 50).round(2) %>%" class="absolute start-[var(--slider-position)] top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2 cursor-grab rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:scale-110 data-[active]:scale-95 data-[active]:cursor-grabbing data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(0_0_0/0.08)] dark:data-[active]:shadow-[0_1px_3px_rgb(0_0_0/0.25),0_0_0_1px_rgb(0_0_0/0.08),0_0_0_6px_rgb(255_255_255/0.14)] outline-0 outline-offset-2 outline-neutral-600 data-[focus-visible]:outline-2 dark:outline-neutral-200 group-data-[jumping]/slider:transition-[inset,scale,box-shadow] forced-colors:forced-color-adjust-none forced-colors:bg-[CanvasText] forced-colors:outline-[CanvasText]"></div>
</div>
</div>
<input type="hidden" name="campaign[alert_at]" value="<%= alert_at %>" data-slider-target="input">
</div>
</div>
<div class="flex items-center justify-between gap-3 border-t border-black/10 px-5 py-3 dark:border-white/10">
<p class="text-xs text-neutral-500 dark:text-neutral-400" aria-live="polite">
<% if params[:campaign].present? %>
Saved <%= number_to_currency(budget, precision: 0) %>, alert at <%= alert_at %>%.
<% else %>
Not saved yet.
<% end %>
</p>
<div class="flex items-center gap-2">
<button type="reset" class="flex items-center justify-center gap-1.5 rounded-lg bg-transparent px-3 py-1.5 text-sm font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">Reset</button>
<button type="submit" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Save</button>
</div>
</div>
<% end %>
<% end %>
ViewComponent and Shared Partial
Prefer Ruby? Slider::Component renders the markup above from a few arguments: one or two values, sizes, the :fluid variant, vertical sliders, tooltips, step marks and leading / trailing slots. It snaps and clamps the values, renders the starting position and formatted value on the server, and raises on unknown options. The shared partial takes the same options as locals and renders the same markup, for apps without ViewComponent (leading and trailing content are locals instead of slots). Both are in the ViewComponent and ERB Template buttons at the top of the page.
<%= render Slider::Component.new(name: "volume", value: 40, label: "Volume") %>
<%= render Slider::Component.new(
name: ["price[min]", "price[max]"],
value: [250, 700],
max: 1200,
step: 50,
min_distance: 50,
label: "Price per night",
thumb_labels: ["Minimum price", "Maximum price"],
format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }
) %>
<%= render Slider::Component.new(name: "commission", value: 12.5, max: 30, step: 0.5, tooltip: :auto,
format: { style: "unit", unit: "percent", minimumFractionDigits: 1 }, label: "Commission") %>
<%# Shared partial: same idea, plain locals %>
<%= render "shared/components/slider/slider", name: "volume", value: 40, label: "Volume" %>
Sliders in Rails Forms
A slider submits through its hidden input targets, so it works with form_with like any other field. Pass the form builder to the component, or use f.hidden_field in plain markup. A Ruby Hash passed through data: is written as JSON, so the format needs no hand-written quotes.
<%= form_with model: @campaign do |f| %>
<%# ViewComponent: names and values come from the form builder %>
<%= render Slider::Component.new(form: f, attribute: :budget, label: "Monthly budget",
min: 100, max: 2000, step: 50, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }) %>
<%# Range: two attributes %>
<%= render Slider::Component.new(form: f, attribute: %i[alert_from alert_to], label: "Alert window",
min: 50, max: 100, step: 5, thumb_labels: ["Alert from", "Alert to"]) %>
<%# Plain markup: the hidden field is the input target %>
<%= tag.div class: "group/slider", style: "--slider-end: 30%",
data: { controller: "slider", slider_format_value: { style: "unit", unit: "percent" } } do %>
<%# …track, fill and thumb from the examples above… %>
<%= f.hidden_field :threshold, data: { slider_target: "input" } %>
<% end %>
<%= f.submit %>
<% end %>
Reacting to Changes
The controller dispatches slider:input on every change and slider:change once a drag or key press ends. Use the first for live previews and the second for saving or filtering. Both carry event.detail.value, values and index. The hidden inputs also fire native input and change events.
<div data-controller="pricing" data-pricing-price-value="12">
<%= render Slider::Component.new(name: "seats", value: 5, min: 1, max: 50, label: "Seats",
data: { action: "slider:input->pricing#update slider:change->pricing#save" }) %>
<p>Total: <span data-pricing-target="total">$60</span>/month</p>
</div>
// app/javascript/controllers/pricing_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["total"];
static values = { price: Number };
update(event) {
const total = event.detail.value * this.priceValue;
this.totalTarget.textContent = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }).format(total);
}
save(event) {
// Runs once per drag or key press, so it is a good place for a fetch or a form submit.
}
}
Related Components
- Sound: Stepped slider plays a note on each step. It combines this controller with the Sound controller.
- Color Picker: Hue and slider pickers for picking hue, saturation and opacity.
- Switch when the setting is only on or off.
Configuration
How the markup works
The controller never positions anything itself. It writes CSS variables and state attributes, and your classes read them, for example start-[var(--slider-position)] on a thumb and end-[calc(100%-var(--slider-end))] on the fill.
- Render the starting position. Put
style="--slider-position: 40%"on each thumb and--slider-start/--slider-endon the controller element. Without them the thumbs sit at 0% until JavaScript loads. - Give the track padding of half a thumb (
px-2.5for a 20px thumb), and center the thumb with-translate-x-1/2 rtl:translate-x-1/2. - Touch and text selection are handled. When your classes leave
touch-actionatauto, the controller setspan-y(pan-xwhen vertical) on the track, so a touch drag moves the thumb instead of the page. It also turns off text selection and the iOS long-press menu on the track. - Turbo morphing works. After a morph refresh, the controller reads the values back from the inputs. Values, thumbs and inputs can also change after connect.
- High contrast. Windows high contrast mode drops backgrounds and shadows. The examples use
forced-colors:classes with system colors, so the thumb and fill stay visible.
Controller Values
All values belong on the element with data-controller="slider". The starting value of each thumb comes from its input target, or from aria-valuenow on the thumb when there is no input. Every value can change after connect: the thumbs are clamped and snapped to the new settings.