Slider

Visual control for selecting numerical values.

To use this component copy our js code:

// RangeSlider class for creating customizable range sliders
class RangeSlider {
  // Static properties shared across all instances
  static instances = [];
  static sliding_vertically = false;
  static EVENT_LISTENER_LIST = "eventListenerList";

  /**
   * Create a new RangeSlider
   * @param {HTMLElement} element - Input element to enhance
   * @param {Object} options - Configuration options
   */
  constructor(element, options) {
    RangeSlider.instances.push(this);
    this.slider_name = "range-slider";
    this.element = element;

    // Default options with overrides from parameters
    this.options = {
      polyfill: true,
      root: document,
      disabled_class: "opacity-40",
      handle_class: "js_range-slider__handle",
      start_event: ["pointerdown", "touchstart", "mousedown"],
      move_event: ["pointermove", "touchmove", "mousemove"],
      end_event: ["pointerup", "touchend", "mouseup"],
      min: null,
      max: null,
      step: null,
      value: null,
      buffer: null,
      stick: null,
      border_radius: 0,
      vertical: false,
      ...options,
    };

    // Use polyfill only if needed
    if (!this.options.polyfill && this.#supports_range()) return;

    this.#initialize_properties();
    this.#create_dom_structure();
    this.#setup_event_listeners();
    this.#add_vertical_slide_scroll_fix();
    this.#init();
  }

  // Helper methods (previously external functions)

  /**
   * Check if element is hidden in DOM
   * @param {Element} element - Element to check
   * @returns {boolean} True if element is hidden
   */
  #is_hidden = (element) =>
    element.offsetWidth === 0 ||
    element.offsetHeight === 0 ||
    element.open === false;

  /**
   * Get hidden parent nodes of element
   * @param {Element} element - Target element
   * @returns {Element[]} Array of hidden parent elements
   */
  #get_hidden_parent_nodes = (element) => {
    const parents = [];
    let node = element.parentNode;
    while (node && this.#is_hidden(node)) {
      parents.push(node);
      node = node.parentNode;
    }
    return parents;
  };

  /**
   * Get element dimension even when element is not visible
   * @param {Element} element - Target element
   * @param {string} key - Dimension type (offsetWidth/offsetHeight)
   * @returns {number} Calculated dimension
   */
  #get_dimension = (element, key) => {
    const hidden_parent_nodes = this.#get_hidden_parent_nodes(element);
    const original_styles = [];
    let dimension = element[key];

    // Temporarily show hidden parents to calculate dimensions
    hidden_parent_nodes.forEach((parent) => {
      original_styles.push({
        display: parent.style.display,
        height: parent.style.height,
        overflow: parent.style.overflow,
        visibility: parent.style.visibility,
        open: parent.open,
      });

      Object.assign(parent.style, {
        display: "block",
        height: "0",
        overflow: "hidden",
        visibility: "hidden",
      });

      if (typeof parent.open !== "undefined") parent.open = true;
    });

    dimension = element[key];

    // Restore original styles
    hidden_parent_nodes.forEach((parent, index) => {
      Object.assign(parent.style, original_styles[index]);
      if (typeof parent.open !== "undefined")
        parent.open = original_styles[index].open;
    });

    return dimension;
  };

  /**
   * Execute callback for element and its ancestors
   * @param {HTMLElement} el - Starting element
   * @param {Function} callback - Callback to execute
   * @param {boolean} and_for_element - Whether to include starting element
   * @returns {HTMLElement} Last processed element
   */
  #for_each_ancestors = (el, callback, and_for_element) => {
    if (and_for_element) callback(el);
    while (el.parentNode && !callback(el)) el = el.parentNode;
    return el;
  };

  /**
   * Trigger custom event on element
   * @param {HTMLElement} el - Target element
   * @param {string} name - Event name
   * @param {Object} data - Event detail data
   */
  #trigger_event = (el, name, data) =>
    el.dispatchEvent(
      new CustomEvent(name.trim(), {
        detail: data,
        bubbles: true,
        cancelable: true,
      })
    );

  /**
   * Add event listeners and store references
   * @param {HTMLElement} el - Target element
   * @param {string[]} events - Event names
   * @param {Function} listener - Event listener
   */
  #add_event_listeners = (el, events, listener) => {
    if (!el[RangeSlider.EVENT_LISTENER_LIST])
      el[RangeSlider.EVENT_LISTENER_LIST] = {};
    events.forEach((event_name) => {
      if (!el[RangeSlider.EVENT_LISTENER_LIST][event_name])
        el[RangeSlider.EVENT_LISTENER_LIST][event_name] = new Set();
      el.addEventListener(event_name, listener, { passive: false });
      el[RangeSlider.EVENT_LISTENER_LIST][event_name].add(listener);
    });
  };

  /**
   * Remove event listeners and cleanup references
   * @param {HTMLElement} el - Target element
   * @param {string[]} events - Event names
   * @param {Function} listener - Event listener to remove
   */
  #remove_event_listeners = (el, events, listener) =>
    events.forEach((event_name) => {
      el.removeEventListener(event_name, listener, false);
      if (el[RangeSlider.EVENT_LISTENER_LIST]?.[event_name]?.has(listener))
        el[RangeSlider.EVENT_LISTENER_LIST][event_name].delete(listener);
    });

  /**
   * Remove all event listeners from element
   * @param {HTMLElement} el - Target element
   */
  #remove_all_listeners_from_el = (el) => {
    if (!el[RangeSlider.EVENT_LISTENER_LIST]) return;
    Object.entries(el[RangeSlider.EVENT_LISTENER_LIST]).forEach(
      ([event_name, listeners]) =>
        listeners.forEach((listener) => {
          if (listener === this._start_event_listener)
            el.removeEventListener(event_name, listener, false);
        })
    );
    el[RangeSlider.EVENT_LISTENER_LIST] = {};
  };

  /**
   * Check if browser supports range input type
   * @returns {boolean} True if range input is supported
   */
  #supports_range = () => document.createElement("input").type === "range";

  /**
   * Generate unique ID
   * @returns {string} Unique identifier
   */
  #uuid = () =>
    crypto.randomUUID?.() ||
    `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  /**
   * Create debounced function
   * @param {Function} fn - Function to debounce
   * @param {number} debounce_duration - Debounce delay in ms
   * @returns {Function} Debounced function
   */
  #debounce = (fn, debounce_duration = 100) => {
    let timeout_id;
    return (...args) => {
      clearTimeout(timeout_id);
      timeout_id = setTimeout(() => fn.apply(window, args), debounce_duration);
    };
  };

  /**
   * Check if value is a string
   * @param {*} obj - Value to check
   * @returns {boolean} True if value is a string
   */
  #is_string = (obj) => typeof obj === "string";

  /**
   * Check if value is number-like (can be converted to number)
   * @param {*} obj - Value to check
   * @returns {boolean} True if value is number-like
   */
  #is_number_like = (obj) =>
    obj !== null &&
    obj !== undefined &&
    ((this.#is_string(obj) && isFinite(parseFloat(obj))) || isFinite(obj));

  /**
   * Get first number-like value from arguments
   * @param {...*} args - Values to check
   * @returns {*} First number-like value or null
   */
  #get_firs_number_like = (...args) =>
    args.find((arg) => this.#is_number_like(arg)) ?? null;

  /**
   * Constrain value between min and max
   * @param {number} pos - Value to constrain
   * @param {number} min - Minimum value
   * @param {number} max - Maximum value
   * @returns {number} Constrained value
   */
  #between = (pos, min, max) => Math.min(Math.max(pos, min), max);

  // Main class methods

  /**
   * Initialize slider properties from options and element attributes
   * @private
   */
  #initialize_properties() {
    // Get values from options or element attributes with fallbacks
    this.options.buffer =
      this.options.buffer ||
      parseFloat(this.element.getAttribute("data-buffer"));
    this.identifier = `js-${this.slider_name}-${this.#uuid()}`;

    this.min = this.#get_firs_number_like(
      this.options.min,
      parseFloat(this.element.getAttribute("min")),
      0
    );

    this.max = this.#get_firs_number_like(
      this.options.max,
      parseFloat(this.element.getAttribute("max")),
      100
    );

    this.value = this.#get_firs_number_like(
      this.options.value,
      this.element.value,
      parseFloat(this.element.value || this.min + (this.max - this.min) / 2)
    );

    this.step = this.#get_firs_number_like(
      this.options.step,
      parseFloat(this.element.getAttribute("step")),
      1
    );

    // Handle stick functionality
    if (Array.isArray(this.options.stick) && this.options.stick.length >= 1) {
      this.stick = this.options.stick;
    } else {
      const stick_attribute = this.element.getAttribute("stick");
      if (stick_attribute) {
        const stick_values = stick_attribute.split(" ").map(parseFloat);
        if (stick_values.length >= 1) {
          this.stick = stick_values;
        }
      }
    }

    if (this.stick && this.stick.length === 1) {
      this.stick.push(this.step * 1.5);
    }

    this.#update_percent_from_value();
    this.to_fixed = this.#to_fixed(this.step);
  }

  /**
   * Create DOM structure for the slider
   * @private
   */
  #create_dom_structure() {
    // Create container elements
    this.container = document.createElement("div");
    this.container.classList.add(
      "flex",
      "absolute",
      "left-0",
      "bg-foreground",
      "w-full",
      "h-full",
      "rounded-8px",
      ...(this.options.vertical ? ["bottom-0"] : ["top-0"])
    );

    this.handle = document.createElement("div");
    this.handle.classList.add(
      this.options.handle_class,
      "cursor-pointer",
      "inline-flex",
      "h-16px",
      "w-16px",
      "absolute",
      "rounded-full",
      "bg-background",
      "border-[2px]",
      "border-foreground",
      ...(this.options.vertical ? ["-left-[7px]", "bottom-0"] : ["-top-[7px]"])
    );

    this.range = document.createElement("div");
    this.range.classList.add(
      "flex",
      "relative",
      "bg-foreground",
      ...(this.options.vertical ? ["w-2px", "h-full"] : ["w-full", "h-2px"])
    );
    this.range.id = this.identifier;

    // Add title if exists
    const element_title = this.element.getAttribute("title");
    if (element_title) {
      this.range.setAttribute("title", element_title);
    }

    // Create buffer element if needed
    if (this.options.buffer) {
      this.buffer = document.createElement("div");
      this.buffer.classList.add(
        "absolute",
        "top-[0px]",
        "h-[2px]",
        "bg-foreground",
        "rounded-8px",
        "active:opacity-80"
      );
      this.range.appendChild(this.buffer);
    }

    // Build DOM structure
    this.range.appendChild(this.container);
    this.range.appendChild(this.handle);
    this.element.parentNode.insertBefore(this.range, this.element.nextSibling);

    // Hide original input but keep it accessible
    Object.assign(this.element.style, {
      position: "absolute",
      width: "1px",
      height: "1px",
      overflow: "hidden",
      opacity: "0",
    });

    // Set initial values
    if (this.#is_number_like(this.options.value)) {
      this.#set_value(this.options.value, true);
    }

    if (this.#is_number_like(this.options.buffer)) {
      this.element.setAttribute("data-buffer", this.options.buffer);
    }

    this.element.setAttribute("min", this.min);
    this.element.setAttribute("max", this.max);
    this.element.setAttribute("step", this.step);
  }

  /**
   * Set up event listeners
   * @private
   */
  #setup_event_listeners() {
    // Bind methods
    this._handle_down = this._handle_down.bind(this);
    this._handle_move = this._handle_move.bind(this);
    this._handle_end = this._handle_end.bind(this);
    this._start_event_listener = this._start_event_listener.bind(this);
    this._change_event_listener = this._change_event_listener.bind(this);
    this._handle_resize = this._handle_resize.bind(this);

    // Add event listeners
    window.addEventListener("resize", this._handle_resize, false);
    this.#add_event_listeners(
      this.options.root,
      this.options.start_event,
      this._start_event_listener
    );
    this.element.addEventListener("change", this._change_event_listener, false);
  }

  /**
   * A lightweight plugin wrapper around the constructor
   * @param {Element|Element[]} el - Element(s) to initialize
   * @param {Object} options - Configuration options
   */
  static create(el, options) {
    const elements = el.length ? Array.from(el) : [el];

    elements.forEach((element) => {
      if (!element[this.prototype.slider_name]) {
        element[this.prototype.slider_name] = new RangeSlider(element, options);
      }
    });
  }

  /**
   * Handle touchmove events to prevent scroll during vertical sliding
   * @param {Event} event - Touch event
   */
  static #touch_move_scroll_handler(event) {
    if (RangeSlider.sliding_vertically) {
      event.preventDefault();
    }
  }

  /* Public methods */

  /**
   * Update slider with new values
   * @param {Object} obj - New values {min, max, value, step, buffer}
   * @param {boolean} trigger_events - Whether to trigger events
   * @returns {RangeSlider} Instance for chaining
   */
  update(obj, trigger_events = false) {
    this.need_trigger_events = trigger_events;

    if (obj && typeof obj === "object") {
      if (this.#is_number_like(obj.min)) {
        this.element.setAttribute("min", obj.min);
        this.min = obj.min;
      }

      if (this.#is_number_like(obj.max)) {
        this.element.setAttribute("max", obj.max);
        this.max = obj.max;
      }

      if (this.#is_number_like(obj.step)) {
        this.element.setAttribute("step", obj.step);
        this.step = obj.step;
        this.to_fixed = this.#to_fixed(obj.step);
      }

      if (this.#is_number_like(obj.buffer)) {
        this.#set_buffer_position(obj.buffer);
      }

      if (this.#is_number_like(obj.value)) {
        this.#set_value(obj.value);
      }
    }

    this.#update();
    this.on_slide_events_count = 0;
    this.need_trigger_events = false;

    return this;
  }

  /**
   * Destroy the slider and clean up
   */
  destroy() {
    this.#remove_all_listeners_from_el(this, this.options.root);
    window.removeEventListener("resize", this._handle_resize, false);
    this.element.removeEventListener(
      "change",
      this._change_event_listener,
      false
    );

    // Restore original element
    this.element.style.cssText = "";
    delete this.element[this.slider_name];

    // Remove generated markup
    if (this.range && this.range.parentNode) {
      this.range.parentNode.removeChild(this.range);
    }

    // Clean up instances
    RangeSlider.instances = RangeSlider.instances.filter(
      (plugin) => plugin !== this
    );

    // Remove scroll fix if no vertical sliders remain
    if (!RangeSlider.instances.some((plugin) => plugin.vertical)) {
      this.#remove_vertical_slide_scroll_fix();
    }
  }

  /* Private methods */

  /**
   * Calculate decimal places for step value
   * @param {number} step - Step value
   * @returns {number} Number of decimal places
   */
  #to_fixed(step) {
    return step.toString().includes(".")
      ? step.toString().split(".")[1].length
      : 0;
  }

  /**
   * Initialize the slider
   * @private
   */
  #init() {
    if (typeof this.on_init === "function") {
      this.on_init();
    }
    this.#update(false);
  }

  /**
   * Update percent value based on current value
   * @private
   */
  #update_percent_from_value() {
    this.percent = (this.value - this.min) / (this.max - this.min);
  }

  /**
   * Handle start events on slider elements
   * @param {Event} ev - Event object
   */
  _start_event_listener(ev) {
    // Only handle primary button clicks and touch events
    if (ev.button > 0 && !ev.touches) return;

    let is_event_on_slider = false;
    const check_slider = (el) => {
      is_event_on_slider =
        el.id === this.identifier &&
        !el.classList.contains(this.options.disabled_class);
      return is_event_on_slider;
    };

    this.#for_each_ancestors(ev.target, check_slider, true);

    if (is_event_on_slider) {
      this._handle_down(ev);
    }
  }

  /**
   * Handle change events from the original input
   * @param {Event} ev - Event object
   */
  _change_event_listener(ev) {
    if (ev.detail?.origin === this.identifier) return;

    const value = ev.target.value;
    const pos = this.#get_position_from_value(value);
    this.#set_position(pos);
  }

  /**
   * Update slider dimensions and position
   * @param {boolean} event - Whether to trigger change event
   * @private
   */
  #update(event = true) {
    const size_property = this.options.vertical
      ? "offsetHeight"
      : "offsetWidth";
    this.handle_size = this.#get_dimension(this.handle, size_property);
    this.range_size = this.#get_dimension(this.range, size_property);
    this.max_handle_x = this.range_size - this.handle_size;
    this.grab_x = this.handle_size / 2;
    this.position = this.#get_position_from_value(this.value);

    // Update disabled state
    if (this.element.disabled) {
      this.range.classList.add(this.options.disabled_class);
    } else {
      this.range.classList.remove(this.options.disabled_class);
    }

    this.#set_position(this.position);

    // Update buffer if enabled
    if (this.options.buffer) {
      this.#set_buffer_position(this.options.buffer);
    }

    this.#update_percent_from_value();

    if (event) {
      this.#trigger_event(this.element, "change", { origin: this.identifier });
    }
  }

  /**
   * Add scroll prevention for vertical sliders
   * @private
   */
  #add_vertical_slide_scroll_fix() {
    if (this.options.vertical && !vertical_sliding_fix_registered) {
      document.addEventListener(
        "touchmove",
        RangeSlider.#touch_move_scroll_handler,
        { passive: false }
      );
      vertical_sliding_fix_registered = true;
    }
  }

  /**
   * Remove scroll prevention for vertical sliders
   * @private
   */
  #remove_vertical_slide_scroll_fix() {
    document.removeEventListener(
      "touchmove",
      RangeSlider.#touch_move_scroll_handler
    );
    vertical_sliding_fix_registered = false;
  }

  /**
   * Handle resize events with debouncing
   */
  _handle_resize() {
    this.#debounce(() => {
      setTimeout(() => this.#update(), 300);
    }, 50)();
  }

  /**
   * Handle pointer/touch/mouse down events
   * @param {Event} e - Event object
   */
  _handle_down(e) {
    this.is_interacts_now = true;
    e.preventDefault();

    this.#add_event_listeners(
      this.options.root,
      this.options.move_event,
      this._handle_move
    );
    this.#add_event_listeners(
      this.options.root,
      this.options.end_event,
      this._handle_end
    );

    // Skip if clicking directly on handle
    if (e.target.closest(`.${this.options.handle_class}`)) {
      return;
    }

    const range_rect = this.range.getBoundingClientRect();
    const pos_x = this.#get_relative_position(e);
    const range_x = this.options.vertical ? range_rect.bottom : range_rect.left;
    const handle_x = this.#get_position_from_node(this.handle) - range_x;
    const position = pos_x - this.grab_x;

    this.#set_position(position);

    // Adjust grab position if clicking near handle
    if (
      pos_x >= handle_x &&
      pos_x < handle_x + this.options.border_radius * 2
    ) {
      this.grab_x = pos_x - handle_x;
    }

    this.#update_percent_from_value();
  }

  /**
   * Handle pointer/touch/mouse move events
   * @param {Event} e - Event object
   */
  _handle_move(e) {
    this.is_interacts_now = true;
    e.preventDefault();

    const pos_x = this.#get_relative_position(e);
    this.#set_position(pos_x - this.grab_x);
  }

  /**
   * Handle pointer/touch/mouse up events
   * @param {Event} e - Event object
   */
  _handle_end(e) {
    e.preventDefault();

    this.#remove_event_listeners(
      this.options.root,
      this.options.move_event,
      this._handle_move
    );
    this.#remove_event_listeners(
      this.options.root,
      this.options.end_event,
      this._handle_end
    );

    // Finalize interaction
    this.#trigger_event(this.element, "change", { origin: this.identifier });

    if (this.is_interacts_now || this.need_trigger_events) {
      if (typeof this.on_slide_end === "function") {
        this.on_slide_end(this.value, this.percent, this.position);
      }

      if (this.options.vertical) {
        RangeSlider.sliding_vertically = false;
      }
    }

    this.on_slide_events_count = 0;
    this.is_interacts_now = false;
  }

  /**
   * Set slider position and update value
   * @param {number} pos - Position to set
   * @private
   */
  #set_position(pos) {
    let value = this.#get_value_from_position(
      this.#between(pos, 0, this.max_handle_x)
    );

    // Apply stick functionality if enabled
    if (this.stick) {
      const stick_to = this.stick[0];
      const stick_radius = this.stick[1] || 0.1;
      const rest_from_value = value % stick_to;

      if (rest_from_value < stick_radius) {
        value = value - rest_from_value;
      } else if (Math.abs(stick_to - rest_from_value) < stick_radius) {
        value = value - rest_from_value + stick_to;
      }
    }

    const position = this.#get_position_from_value(value);

    // Update UI
    if (this.options.vertical) {
      this.container.style.height = `${position + this.grab_x}px`;
      this.handle.style.transform = `translateY(-${position}px)`;
    } else {
      this.container.style.width = `${position + this.grab_x}px`;
      this.handle.style.transform = `translateX(${position}px)`;
    }

    this.#set_value(value);

    // Update instance properties
    this.position = position;
    this.value = value;
    this.#update_percent_from_value();

    // Trigger events if needed
    if (this.is_interacts_now || this.need_trigger_events) {
      if (
        typeof this.on_slide_start === "function" &&
        this.on_slide_events_count === 0
      ) {
        this.on_slide_start(this.value, this.percent, this.position);
      }

      if (typeof this.on_slide === "function") {
        this.on_slide(this.value, this.percent, this.position);
      }

      if (this.options.vertical) {
        RangeSlider.sliding_vertically = true;
      }
    }

    this.on_slide_events_count++;
  }

  /**
   * Set buffer position
   * @param {number|string} pos - Buffer position (percentage or pixels)
   * @private
   */
  #set_buffer_position(pos) {
    let is_percent = true;
    let buffer_value = pos;

    // Parse position value
    if (isFinite(pos)) {
      buffer_value = parseFloat(pos);
    } else if (this.#is_string(pos)) {
      is_percent = !pos.includes("px");
      buffer_value = parseFloat(pos);
    } else {
      console.warn("Buffer position must be a number or string (XXpx or XX%)");
      return;
    }

    if (isNaN(buffer_value)) {
      console.warn("Buffer position is not a valid number");
      return;
    }

    if (!this.options.buffer) {
      console.warn("Buffer is disabled");
      return;
    }

    // Calculate buffer size
    let buffer_size = is_percent
      ? buffer_value
      : (buffer_value / this.range_size) * 100;
    buffer_size = this.#between(buffer_size, 0, 100);
    this.options.buffer = buffer_size;

    // Calculate padding
    const padding_size = (this.options.border_radius / this.range_size) * 100;
    let buffer_size_with_padding = buffer_size - padding_size;

    if (buffer_size_with_padding < 0) {
      buffer_size_with_padding = 0;
    }

    // Apply styles
    if (this.options.vertical) {
      this.buffer.style.height = `${buffer_size_with_padding}%`;
      this.buffer.style.bottom = `${padding_size * 0.5}%`;
    } else {
      this.buffer.style.width = `${buffer_size_with_padding}%`;
      this.buffer.style.left = `${padding_size * 0.5}%`;
    }

    this.element.setAttribute("data-buffer", buffer_size);
  }

  /**
   * Get element position relative to parent
   * @param {Element} node - Element to check
   * @returns {number} Position value
   * @private
   */
  #get_position_from_node(node) {
    let position = this.options.vertical ? this.max_handle_x : 0;

    while (node !== null) {
      position += this.options.vertical ? node.offsetTop : node.offsetLeft;
      node = node.offsetParent;
    }

    return position;
  }

  /**
   * Get relative position from event coordinates
   * @param {Event} e - Mouse or touch event
   * @returns {number} Relative position
   * @private
   */
  #get_relative_position(e) {
    const range_rect = this.range.getBoundingClientRect();
    const range_position = this.options.vertical
      ? range_rect.bottom
      : range_rect.left;

    // Get coordinates from event
    let client_x, client_y;

    if (e.touches?.[0]) {
      client_x = e.touches[0].clientX;
      client_y = e.touches[0].clientY;
    } else {
      client_x = e.clientX;
      client_y = e.clientY;
    }

    const scroll_x = window.pageXOffset;
    const scroll_y = window.pageYOffset;

    const page_x = client_x + scroll_x;
    const page_y = client_y + scroll_y;

    if (this.options.vertical) {
      return range_position - page_y;
    } else {
      return page_x - range_position;
    }
  }

  /**
   * Convert value to position
   * @param {number} value - Value to convert
   * @returns {number} Position
   * @private
   */
  #get_position_from_value(value) {
    const percentage = (value - this.min) / (this.max - this.min);
    const position = percentage * this.max_handle_x;
    return isNaN(position) ? 0 : position;
  }

  /**
   * Convert position to value
   * @param {number} pos - Position to convert
   * @returns {number} Value
   * @private
   */
  #get_value_from_position(pos) {
    const percentage = pos / (this.max_handle_x || 1);
    const value = this.min + percentage * (this.max - this.min);
    const stepped_value = this.step * Math.round(value / this.step);
    return Number(stepped_value.toFixed(this.to_fixed));
  }

  /**
   * Set slider value
   * @param {number} value - Value to set
   * @param {boolean} force - Whether to force update
   * @private
   */
  #set_value(value, force = false) {
    if (value === this.value && !force) return;

    this.element.value = value;
    this.value = value;
    this.#trigger_event(this.element, "input", { origin: this.identifier });
  }
}

// Track if vertical sliding fix is registered
let vertical_sliding_fix_registered = false;

/**
 * Initialize all range sliders on the page
 * @param {Object} options - Configuration options
 */
export const init_range_sliders = (options = {}) => {
  RangeSlider.instances = [];
  document.querySelectorAll('input[type="range"]').forEach((slider) => {
    if (!slider[RangeSlider.prototype.slider_name]) {
      slider[RangeSlider.prototype.slider_name] = new RangeSlider(slider, options);
    }
  });
};

Then init range sliders:

init_range_sliders();

Params

Available parameters for use in init_range_sliders:

{
  polyfill: true,              // Whether to apply polyfill (if native  is not supported)
  root: document,              // Root element for attaching global event listeners
  start_event: ["pointerdown", "touchstart", "mousedown"], // Events that start dragging
  move_event: ["pointermove", "touchmove", "mousemove"],   // Events that handle dragging movement
  end_event: ["pointerup", "touchend", "mouseup"],         // Events that stop dragging
  min: null,                   // Minimum slider value (falls back to )
  max: null,                   // Maximum slider value (falls back to )
  step: null,                  // Step increment (falls back to )
  value: null,                 // Initial value (falls back to )
  buffer: null,                // Buffer position (fallback from data-buffer attribute)
  stick: null,                  // Array [stick_value, stick_radius] → snapping functionality
  vertical: false,              // Whether slider is vertical (default = horizontal)
};

Usage