Slider Shared Partial

Don't forget the Stimulus controller!
The shared partial still requires the slider_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Copy shared partial files

Download all 1 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the slider/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/slider/ directory:

2. Usage example

<%# The wrapper only gives these examples a width: a slider fills its container %>
<div class="w-80 max-w-full space-y-8">
<!-- Basic slider: a hidden input named "volume" submits the value -->
<%= render "shared/components/slider/slider", name: "volume", value: 40, label: "Volume" %>

<!-- In a form: the form builder makes the name ("campaign[budget]").
     With form_with model: @campaign, leave out value: and it is read from @campaign.budget -->
<%= form_with scope: :campaign, url: "#" do |f| %>
  <%= render "shared/components/slider/slider",
    form: f,
    attribute: :budget,
    value: 600,
    label: "Monthly budget",
    min: 100,
    max: 2000,
    step: 50,
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }
  %>
<% end %>

<!-- Range slider: two attributes (or two names and two values) -->
<%= form_with scope: :search, url: "#", method: :get do |f| %>
  <%= render "shared/components/slider/slider",
    form: f,
    attribute: %i[price_min price_max],
    value: [250, 700],
    label: "Price per night",
    max: 1200,
    step: 50,
    min_distance: 50,
    thumb_labels: ["Minimum price", "Maximum price"],
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }
  %>
<% end %>

<!-- Named steps with clickable marks under the track -->
<%= render "shared/components/slider/slider",
  name: "export[quality]",
  value: 2,
  max: 4,
  labels: %w[Draft Good High Ultra Lossless],
  marks: true,
  label: "Export quality"
%>

<!-- Value tooltip above the thumb (:auto shows it on hover, focus and drag only) -->
<%= render "shared/components/slider/slider",
  name: "affiliate[commission]",
  value: 12.5,
  max: 30,
  step: 0.5,
  tooltip: :auto,
  format: { style: "unit", unit: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 },
  label: "Affiliate commission"
%>

<!-- Buttons before and after the track. They sit inside the controller, so slider actions work, and they dim at the ends of the range -->
<% lower = capture do %>
  <button type="button" aria-label="Lower volume" data-action="click->slider#decrement" data-slider-amount-param="10"
          class="flex size-8 select-none items-center justify-center rounded-lg hover:bg-neutral-100 group-data-[at-min]/slider:opacity-40 dark:hover:bg-neutral-800">−</button>
<% end %>
<% raise_volume = capture do %>
  <button type="button" aria-label="Raise volume" data-action="click->slider#increment" data-slider-amount-param="10"
          class="flex size-8 select-none items-center justify-center rounded-lg hover:bg-neutral-100 group-data-[at-max]/slider:opacity-40 dark:hover:bg-neutral-800">+</button>
<% end %>
<%= render "shared/components/slider/slider", name: "volume", value: 60, label: "Volume", hide_label: true, output: false,
  leading: lower, trailing: raise_volume %>

<!-- Vertical, with a unit for screen readers -->
<%= render "shared/components/slider/slider",
  name: "equalizer[60]",
  value: 6,
  min: -12,
  max: 12,
  orientation: :vertical,
  track_length: "h-36",
  format: { signDisplay: "exceptZero" },
  value_text: "{value} decibels",
  label: "60 Hz"
%>

<!-- Fluid: a thick bar with the label inside that stretches when pulled past an end -->
<%= render "shared/components/slider/slider",
  name: "display[brightness]",
  value: 70,
  label: "Brightness",
  variant: :fluid,
  format: { style: "unit", unit: "percent" }
%>

<!-- Small, disabled, with a hint linked by aria-describedby -->
<%= render "shared/components/slider/slider",
  name: "storage_limit",
  value: 10,
  max: 50,
  size: :sm,
  disabled: true,
  hint: "Upgrade to Pro to raise your storage limit.",
  label: "Storage limit"
%>

<!-- Large, with extra attributes on the root. Listen to slider:input or slider:change from your own controller with data: { action: ... } -->
<%= render "shared/components/slider/slider",
  name: "seats",
  value: 5,
  min: 1,
  max: 50,
  size: :lg,
  label: "Seats",
  id: "seats_slider",
  data: { turbo_permanent: true }
%>
</div>

Rendered usage example

Volume 40
Monthly budget $600
Price per night $250 – $700
Export quality High
Affiliate commission
12.5%
Volume
+6
60 Hz
Brightness 70%
Storage limit 10

Upgrade to Pro to raise your storage limit.

Seats 5

Slider ViewComponent

Don't forget the Stimulus controller!
The ViewComponent still requires the slider_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 2 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the slider/ folder to your app/components/ directory.

Copy these files to your app/components/slider/ directory:

3. Usage example

<%# The wrapper only gives these examples a width: a slider fills its container %>
<div class="w-80 max-w-full space-y-8">
<!-- Basic slider: a hidden input named "volume" submits the value -->
<%= render Slider::Component.new(name: "volume", value: 40, label: "Volume") %>

<!-- In a form: the form builder makes the name ("campaign[budget]").
     With form_with model: @campaign, leave out value: and it is read from @campaign.budget -->
<%= form_with scope: :campaign, url: "#" do |f| %>
  <%= render Slider::Component.new(
    form: f,
    attribute: :budget,
    value: 600,
    label: "Monthly budget",
    min: 100,
    max: 2000,
    step: 50,
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }
  ) %>
<% end %>

<!-- Range slider: two attributes (or two names and two values) -->
<%= form_with scope: :search, url: "#", method: :get do |f| %>
  <%= render Slider::Component.new(
    form: f,
    attribute: %i[price_min price_max],
    value: [250, 700],
    label: "Price per night",
    max: 1200,
    step: 50,
    min_distance: 50,
    thumb_labels: ["Minimum price", "Maximum price"],
    format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }
  ) %>
<% end %>

<!-- Named steps with clickable marks under the track -->
<%= render Slider::Component.new(
  name: "export[quality]",
  value: 2,
  max: 4,
  labels: %w[Draft Good High Ultra Lossless],
  marks: true,
  label: "Export quality"
) %>

<!-- Value tooltip above the thumb (:auto shows it on hover, focus and drag only) -->
<%= render Slider::Component.new(
  name: "affiliate[commission]",
  value: 12.5,
  max: 30,
  step: 0.5,
  tooltip: :auto,
  format: { style: "unit", unit: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 },
  label: "Affiliate commission"
) %>

<!-- Buttons before and after the track. They sit inside the controller, so slider actions work, and they dim at the ends of the range -->
<%= render Slider::Component.new(name: "volume", value: 60, label: "Volume", hide_label: true, output: false) do |slider| %>
  <% slider.with_leading do %>
    <button type="button" aria-label="Lower volume" data-action="click->slider#decrement" data-slider-amount-param="10"
            class="flex size-8 select-none items-center justify-center rounded-lg hover:bg-neutral-100 group-data-[at-min]/slider:opacity-40 dark:hover:bg-neutral-800">−</button>
  <% end %>
  <% slider.with_trailing do %>
    <button type="button" aria-label="Raise volume" data-action="click->slider#increment" data-slider-amount-param="10"
            class="flex size-8 select-none items-center justify-center rounded-lg hover:bg-neutral-100 group-data-[at-max]/slider:opacity-40 dark:hover:bg-neutral-800">+</button>
  <% end %>
<% end %>

<!-- Vertical, with a unit for screen readers -->
<%= render Slider::Component.new(
  name: "equalizer[60]",
  value: 6,
  min: -12,
  max: 12,
  orientation: :vertical,
  track_length: "h-36",
  format: { signDisplay: "exceptZero" },
  value_text: "{value} decibels",
  label: "60 Hz"
) %>

<!-- Fluid: a thick bar with the label inside that stretches when pulled past an end -->
<%= render Slider::Component.new(
  name: "display[brightness]",
  value: 70,
  label: "Brightness",
  variant: :fluid,
  format: { style: "unit", unit: "percent" }
) %>

<!-- Small, disabled, with a hint linked by aria-describedby -->
<%= render Slider::Component.new(
  name: "storage_limit",
  value: 10,
  max: 50,
  size: :sm,
  disabled: true,
  hint: "Upgrade to Pro to raise your storage limit.",
  label: "Storage limit"
) %>

<!-- Large, with extra attributes on the root. Listen to slider:input or slider:change from your own controller with data: { action: ... } -->
<%= render Slider::Component.new(
  name: "seats",
  value: 5,
  min: 1,
  max: 50,
  size: :lg,
  label: "Seats",
  id: "seats_slider",
  data: { turbo_permanent: true }
) %>
</div>

Rendered usage example

Volume 40
Monthly budget $600
Price per night $250 – $700
Export quality High
Affiliate commission
12.5%
Volume
+6
60 Hz
Brightness 70%
Storage limit 10

Upgrade to Pro to raise your storage limit.

Seats 5

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.

Volume 40
<%# 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.

Export quality High
<% 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.

Price per night $250 – $700
<%# 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.

Affiliate commission
12.5%
<% 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.

45
<!-- 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.

Brightness 70%
Volume 35%
<% 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.

Position
X
Y
°
Appearance
%
px

Drag a field to scrub. Hold Shift for 10x or Alt for 0.1x. Click to type.

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".

Equalizer
+6
60
+3
250
-4
1k
+2
4k
+7
16k
<% 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.

Storage limit 10 GB

Upgrade to Pro to raise your storage limit.

<% 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.

Monthly budget $600
Alert me at 80%

Not saved yet.

<%# 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.
  }
}

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-end on the controller element. Without them the thumbs sit at 0% until JavaScript loads.
  • Give the track padding of half a thumb (px-2.5 for 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-action at auto, the controller sets pan-y (pan-x when 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.
Name Set on Description
--slider-position Each thumb Where that thumb sits, from 0% to 100%.
--slider-start Controller Where the fill starts: 0% with one thumb, the first thumb with two.
--slider-end Controller Where the fill ends: the last thumb.
--slider-stretch Controller Rubber-band amount (0 to 0.08) while dragging past an end. Only with rubberBand.
data-dragging Controller Present while a pointer is down on the track.
data-jumping Controller Present for 200ms after a press on the track. Scope position transitions to it so the thumb glides to the pointer, while drags and key presses stay instant.
data-disabled Controller Present while the slider is disabled. Style the disabled look with it, and render it in the HTML too.
data-at-min Controller Present while the first thumb is at min. Dim a decrease button with it.
data-at-max Controller Present while the last thumb is at max.
data-active Thumb, tick On a thumb while it is pressed. On a tick while its data-value is inside the filled range.
data-focus-visible Thumb Present while the thumb has keyboard focus. Style the focus ring with it: a press on the track focuses the thumb from script, which would otherwise trigger :focus-visible for mouse users.
data-overshoot Controller "min" or "max": which end is being stretched. Use it to set the transform origin.
data-slider-initial Controller Written by the controller: the starting values, used on form reset. It survives the Turbo cache, so Reset brings back the server values, not the last ones.

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.

Value Data attribute Type Default Description
min data-slider-min-value Number 0 Lowest value.
max data-slider-max-value Number 100 Highest value.
step data-slider-step-value Number 1 Values snap to multiples of the step. Decimals such as 0.5 work.
largeStep data-slider-large-step-value Number 10% of the range Amount for Page Up / Page Down and Shift + arrow keys.
minDistance data-slider-min-distance-value Number 0 Smallest gap between two thumbs. At 0 the thumbs can meet, and dragging on hands over to the other thumb.
orientation data-slider-orientation-value String "horizontal" Use "vertical" to read the pointer on the Y axis, bottom to top.
disabled data-slider-disabled-value Boolean false Ignores input, removes thumbs from the tab order and disables the inputs. Can be toggled at any time.
rubberBand data-slider-rubber-band-value Boolean false Publishes --slider-stretch and data-overshoot when dragged past an end. Skipped for reduced motion.
format data-slider-format-value Object {} Intl.NumberFormat options for outputs and aria-valuetext, such as {"style":"currency","currency":"USD"}.
valueText data-slider-value-text-value String "" Screen reader text with {value} in it, such as "{value} decibels". Use it when the number alone leaves out the unit.
labels data-slider-labels-value Array [] Names for each step, such as ["Low","Medium","High"]. Used instead of the number in outputs and aria-valuetext.

Targets

Target Description
track The pressable area. The thumb travels inside its padding, so give it padding of half a thumb. Defaults to the controller element.
thumb One per value, two for a range. The controller adds role="slider", tabindex and all aria-value attributes. Give each one an aria-label or aria-labelledby.
input Optional. One per thumb, in the same order. Receives the value and native input / change events.
output Optional. Shows the formatted value. Inside a thumb, or with data-index, it shows that one thumb; otherwise the whole range.
field Optional. An editable text input bound to one thumb by data-index. Shows the formatted value, a plain number while focused, and commits on Enter or blur. Arrow Up / Down step the value (Shift = large step). Typed numbers follow the page language. It has no name: the input targets submit the values.
tick Optional. Any element with a data-value. Gets data-active while that value is inside the filled range.

Actions and Events

Name Type Description
slider#increment Action Raises the value by one step, or by data-slider-amount-param. data-slider-index-param picks the thumb (defaults to the last).
slider#decrement Action Lowers the value the same way.
slider#set Action Sets the value to data-slider-value-param.
slider#toggle Action Sets the value to min (mute), or back to the value it had before (the middle of the range if there is none). The button gets aria-pressed="true" while the value is at min.
slider#focus Action Focuses the first thumb. Put it on a visible label.
slider:input Event Fires on every value change with event.detail.value, values and index.
slider:change Event Fires once when a drag ends or a key press changes the value. Escape during a drag puts the thumbs back and fires no change.

Keyboard

Key Action
Arrow RightArrow Up Increase by one step. Arrow Right decreases in right-to-left layouts.
Arrow LeftArrow Down Decrease by one step.
Shift + ArrowPage UpPage Down Increase or decrease by the large step (10% of the range unless largeStep is set).
HomeEnd Jump to min or max.
Escape While dragging: put the thumbs back where the drag started.
EnterEscape In a field target: commit the typed number, or throw it away.

Scrubby Slider Values

Values, targets and events for the Scrubby Slider are available to users.

Table of contents

Powered by

Get notified when new components come out