Came across this tutorial Let’s Make a Multi-Thumb Slider That Calculates The Width Between Thumbs which was written in React and TypeScript. Decided to convert it to vanilla (plain) CSS and JavaScript 🙂
First up, a working demo below. Move the slider thumbs and an event will be emitted, which will be output to the console and displayed below the slider as well.
Source code:
<style>
#notes {
font-family: monospace;
width: 100%;
word-wrap: break-word;
}
.slider {
background-color: transparent;
display: flex;
width: 100%;
}
.tag {
border-left-width: 0.1em;
border-left-style: solid;
border-left-color: white;
border-right-width: 0.1em;
border-right-style: solid;
border-right-color: white;
box-sizing: border-box;
padding: 0.5em;
position: relative;
text-align: center;
}
.tag-text {
color: white;
display: block;
font-family: sans-serif;
font-weight: bold;
overflow: hidden;
user-select: none;
}
.tag:first-of-type {
border-radius: 4px 0px 0px 4px;
}
.tag:last-of-type {
border-radius: 0px 4px 4px 0px;
}
.tag:last-of-type > .slider-thumb {
display: none !important;
}
.slider-thumb {
align-items: center;
background-color: white;
border-radius: 2em;
bottom: 0;
cursor: ew-resize;
display: flex;
height: 2em;
justify-content: center;
margin: auto;
width: 2em;
position: absolute;
right: calc(-1.1em);
top: 0;
user-select: none;
z-index: 10;
}
.slider-thumb span {
font-size: 1.5em;
font-weight: bold;
}
</style>
<div class="slider"></div>
<div id="notes"></div><br>
<script>
let sliderElement = document.querySelector('.slider');
let notesElement = document.getElementById('notes');
sliderElement.addEventListener('demo.slider.tags', (event) => {
let text = 'Updated tag values:' + JSON.stringify(event.detail.tags);
console.log(text);
notesElement.innerHTML = text;
});
// Adapted into vanilla CSS/JavaScript from React/TypeScript in article
// https://newsakmi.com/news/tech-news/software/lets-make-a-multi-thumb-slider-that-calculates-the-width-between-thumbs
// Unlike the original article, the tags here are not removed if value becomes 0.
const demoScript = (function (sliderElement) {
/**
* Self reference - all public properties/methods are stored here and returned as public interface
*
* @public
* @type {object}
*/
const self = {};
/**
* Tags, i.e. sections, in slider
*
* At any one time, the sum of values for all the tags will always be
* the sum of the initial values here, and not fixed at 100 (percent).
* The values shown in this demo just happen to add up to 100.
*
* @private
* @type {object[]}
*/
let tags = [
{ name: 'Sky', color: 'red', value: 70 },
{ name: 'Earth', color: 'green', value: 20 },
{ name: 'Ocean', color: 'blue', value: 10 },
];
window.addEventListener('DOMContentLoaded', (event) => {
let html = '';
tags.forEach((tag, index) => {
// Using pointerdown instead of mousedown to capture both mouse and touchscreen
// events. See
// https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onpointerdown
// for more info.
html += `
<div class="tag" data-tag-index="${index}"
style="background:${tag.color}; width:${tag.value}%;">
<span class="tag-text tag-name">${tag.name}</span>
<span class="tag-text tag-value">${tag.value}%</span>
<div class="slider-thumb"
onpointerdown="demoScript.onSliderSelect(event, this);"><span>⬌</span></div>
</div>
`;
});
sliderElement.innerHTML = html;
});
/**
* Handler when user clicks on slider thumb
*
* @param {Event} event
* @returns {void}
*/
self.onSliderSelect = function (event, sliderThumb) {
event.preventDefault();
document.body.style.cursor = 'ew-resize';
let tagIndex = parseInt(sliderThumb.parentNode.getAttribute('data-tag-index'));
let tag = tags[tagIndex];
let startDragX = event.pageX;
let sliderWidth = sliderElement.offsetWidth;
let values = tags.map((tag) => tag.value); // initial values
// The onResize and onEventUp listeners are specific to each
// onSliderSelect event hence created inside
let onResize = function (event) {
event.preventDefault();
let endDragX = event.touches ? event.touches[0].pageX : event.pageX;
let distanceMoved = endDragX - startDragX;
values = tags.map((tag) => tag.value); // read in updated values
let maxValue = values[tagIndex] + values[tagIndex + 1];
let valueMoved = nearestMultiple(1, getPercent(sliderWidth, distanceMoved));
let prevValue = values[tagIndex];
let newValue = prevValue + valueMoved;
let currTagValue = clamp(newValue, 0, maxValue);
values[tagIndex] = currTagValue;
let nextTagIndex = tagIndex + 1;
let nextTagNewValue = values[nextTagIndex] - valueMoved;
let nextTagValue = clamp(nextTagNewValue, 0, maxValue);
values[nextTagIndex] = nextTagValue;
// Update slider
Array.from(document.querySelectorAll('.tag')).forEach((tagElement) => {
let tagIndex = parseInt(tagElement.getAttribute('data-tag-index'));
let value = values[tagIndex];
tagElement.style.width = value + '%';
tagElement.querySelector('.tag-value').innerHTML = value + '%';
});
};
let removeEventListener = function () {
window.removeEventListener('pointermove', onResize);
window.removeEventListener('touchmove', onResize);
// Update values only when user stops moving slider thumb
tags.forEach((tag, tagIndex) => {
tag.value = values[tagIndex];
});
emitEvent();
};
let onEventUp = function (event) {
event.preventDefault();
document.body.style.cursor = 'initial';
removeEventListener();
};
window.addEventListener('pointermove', onResize);
window.addEventListener('touchmove', onResize);
window.addEventListener("touchend", onEventUp);
window.addEventListener("pointerup", onEventUp);
};
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function emitEvent() {
sliderElement.dispatchEvent(new CustomEvent('demo.slider.tags', {
bubbles: false,
detail: {
tags: tags,
}
}));
}
function getPercent(containerWidth, distanceMoved) {
return (distanceMoved / containerWidth) * 100;
}
function nearestMultiple(divisor, number) {
return Math.ceil(number / divisor) * divisor;
}
// Return public interface of IIFE
return self;
})(sliderElement);
</script>

