Multi-thumb slider in vanilla CSS and JavaScript

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>