Getting started
Components
Extra
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)
};