Overview

Please note that this tutorial was originally posted on Medium here.

In this tutorial, we will learn how to build a drag slider in javascript, and enhance it with powerful WebGL capabilities.

WebGL drag slider gif animation

The slider will be written in 3 parts. In the first part, we’re going to write a Slider class that will create a neat responsive drag slider. In the second part we’re going to extend it to add the WebGL effect. In the third and last part, we'll see how to improve the overall performance by removing all unnecessary reflow / layout calls.

The WebGL part will be handled by curtains.js, an open source vanilla javascript library. It will be particularly useful here as its main purpose is to enhance DOM elements with WebGL effects. With few lines of javascript, you will be able to create WebGL textured planes bound to our slider items HTML elements and then post-process the whole scene.

We can then easily see the advantages of using curtains.js:

  1. Clean & SEO friendly HTML code.

  2. You don’t have to worry about your WebGL objects sizes and positions as most of the stuff (like resize) will be handled under the hood by the library.

  3. If in any case there’s an error during the WebGL initialization or in your shaders, the slider will still work!

Anyway we’ll see that in part two, for now we’ll just take care of the javascript drag slider. This is what we’ll have at the end of the first part:

Part 1. HTML & CSS

HTML

HTML is really basic. We’re just going to add a slider.steup.js javascript file just before the body closing tag, this is where you’ll put your javascript code.

  <body>

   <div id="content">

        <h1 id="title">My awesome slider</h1>

        <!-- drag slider -->
      <div id="planes">

         <div class="plane-wrapper">
            <span class="plane-title">Slide 1</span>
                <div class="plane">
                    <img src="images/slide-1.jpg" alt="First slide" />
                </div>
         </div>

         <div class="plane-wrapper">
            <span class="plane-title">Slide 2</span>
                <div class="plane">
                    <img src="images/slide-2.jpg" alt="Second slide" />
                </div>
         </div>

         <div class="plane-wrapper">
            <span class="plane-title">Slide 3</span>
                <div class="plane">
                    <img src="images/slide-3.jpg" alt="Third slide" />
                </div>
         </div>

         <!-- add more slides if you want... -->

      </div>

        <div id="drag-tip">
            Drag to explore
        </div>

   </div>

   <script src="js/slider.setup.js" type="text/javascript"></script>
</body>

CSS

The CSS is also really easy. Our #planes div will use flexbox to display its children. In landscape mode we will set its width based on the width of the slides and their number. On portrait mode, we will reset its width and change the flex direction to column. We will add a few CSS properties during drag: preventing text selection and some animations.

  @media screen {

    html, body {
        min-height: 100%;
    }

    body {
        margin: 0;
        font-size: 18px;
        font-family: 'Arvo', Verdana, sans-serif;
        background: #ece5d1;
        line-height: 1.4;
        overflow: hidden;
    }

    /*** content ***/

    #content {
        position: relative;
        z-index: 2;
        overflow: hidden;
    }

    #title {
        position: fixed;
        top: 20px;
        right: 20px;
        left: 20px;
        z-index: 1;
        pointer-events: none;
        font-size: 1.5em;
        line-height: 1;
        margin: 0;
        text-transform: uppercase;
        color: #032f4d;
        text-align: center;
    }

    #planes {
        /* width of items * number of items */
        width: calc(((100vw / 1.75) + 10vw) * 7);

        padding: 0 2.5vw;
        height: 100vh;
        display: flex;
        align-items: center;

        cursor: move;

        transition: background-color 0.5s;
    }

    #planes.dragged {
        background-color: #03879a;
    }

    .plane-wrapper {
        position: relative;

        width: calc(100vw / 1.75);
        height: 70vh;
        margin: auto 5vw;
        text-align: center;
    }

    /* disable pointer events and text selection during drag */
    #planes.dragged .plane-wrapper {
        pointer-events: none;

        -webkit-touch-callout: none;
        -webkit-user-select: none;
        -khtml-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
    }

    .plane-title {
        position: absolute;
        top: 50%;
        left: 50%;
        z-index: 1;
        transform: translate3D(-50%, -50%, 0);
        font-size: 4vw;
        font-weight: 700;
        line-height: 1.2;
        text-transform: uppercase;
        color: #032f4d;
        text-stroke: 1px white;
        -webkit-text-stroke: 1px white;

        transition: color 0.5s;
    }

    #planes.dragged .plane-title {
        color: transparent;
    }

    .plane {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        display: flex;
        overflow: hidden;
        transition: filter 0.5s;
    }

    #planes.dragged .plane {
        filter: grayscale(1);
    }

    .plane img {
        display: block;
        min-width: 100%;
        min-height: 100%;
        object-fit: cover;

        /* prevent original image from dragging */
        pointer-events: none;
        -webkit-user-drag: none;
        -khtml-user-drag: none;
        -moz-user-drag: none;
        -o-user-drag: none;
        user-drag: none;
    }

    #drag-tip {
        position: fixed;
        right: 20px;
        bottom: 20px;
        left: 20px;
        z-index: 1;
        pointer-events: none;
        font-size: 0.9em;
        text-transform: uppercase;
        color: #032f4d;
        text-align: center;
    }

}

@media screen and (orientation: portrait) {

    #content {
        max-height: 100vh;
    }

    #planes {
        overflow: hidden;
        width: 100vw;

        padding: 2.5vh 0;
        height: auto;
        flex-direction: column;
    }

    .plane-wrapper {
        width: 70vw;
        height: calc(100vh / 1.75);
        margin: 5vw 0;
    }

    .plane-title {
        font-size: 10vw;
    }

}

Part 2. The drag slider javascript

Ok, let’s build our slider.

All in all, there’s 3 main steps in this slider:

  1. On mouse down, we’ll start dragging our slider and get our drag start position. The slider starts translating.

  2. On mouse move, if the button is still down, we will update our slider current position by adding our mouse last drag end position and our mouse position minus our mouse drag start position.

  3. On mouse up we’ll get our mouse drag end position to use for next drag. Note that the slider is still translating until our linear interpolation is complete.

We will continuously interpolate the position of our slider between its current translation and our mouse drag movement position. We’ll set up a request animation frame loop to handle that. We’ll start by creating a Slider class that will handle our slider and init its variables. We’ll write three little helper functions that we’ll use to interpolate values, retrieve mouse/touch positions and set the slider direction and boundaries. We’ll also add a few hooks that will be useful later, when we’ll extend the Slider class to add the WebGL part:

  class Slider {

    /*** CONSTRUCTOR ***/

    constructor(options = {}) {
        // our options
        this.options = {
            // slider state and values
            // the div we are going to translate
            element: options.element || document.getElementById("planes"),
            // easing value, the lower the smoother
            easing: options.easing || 0.1,
            // translation speed
            // 1: will follow the mouse
            // 2: will go twice as fast as the mouse, etc
            dragSpeed: options.dragSpeed || 1,
            // duration of the in animation
            duration: options.duration || 750,
        };

        // if we are currently dragging
        this.isMouseDown = false;
        // if the slider is currently translating
        this.isTranslating = false;

        // current position
        this.currentPosition = 0;
        // drag start position
        this.startPosition = 0;
        // drag end position
        this.endPosition = 0;

        // slider translation
        this.translation = 0;

        this.animationFrame = null;

        // we'll need to set up our slider here...
    }

    /*** HELPERS ***/

    // lerp function used for easing
    lerp(value1, value2, amount) {
        amount = amount < 0 ? 0 : amount;
        amount = amount > 1 ? 1 : amount;
        return (1 - amount) * value1 + amount * value2;
    }

    // return our mouse or touch position
    getMousePosition(e) {
        var mousePosition;
        if(e.targetTouches) {
            if(e.targetTouches[0]) {
                mousePosition = [e.targetTouches[0].clientX, e.targetTouches[0].clientY];
            }
            else if(e.changedTouches[0]) {
                // handling touch end event
                mousePosition = [e.changedTouches[0].clientX, e.changedTouches[0].clientY];
            }
            else {
                // fallback
                mousePosition = [e.clientX, e.clientY];
            }
        }
        else {
            mousePosition = [e.clientX, e.clientY];
        }

        return mousePosition;
    }

    // set the slider boundaries
    // we will translate it horizontally in landscape mode
    // vertically in portrait mode
    setBoundaries() {
        if(window.innerWidth >= window.innerHeight) {
            // landscape
            this.boundaries = {
                max: -1 * this.options.element.clientWidth + window.innerWidth,
                min: 0,
                sliderSize: this.options.element.clientWidth,
                referentSize: window.innerWidth,
            };

            // set our slider direction
            this.direction = 0;
        }
        else {
            // portrait
            this.boundaries = {
                max: -1 * this.options.element.clientHeight + window.innerHeight,
                min: 0,
                sliderSize: this.options.element.clientHeight,
                referentSize: window.innerHeight,
            };

            // set our slider direction
            this.direction = 1;
        }
    }

    /*** HOOKS ***/

    // this is called once our mousedown / touchstart event occurs and the drag starts
    onDragStarted(mousePosition) {
        // we'll use it later
        // note the mouse/touch position parameter
    }

    // this is called while we are currently dragging the slider
    onDrag(mousePosition) {
        // we'll use it later
        // note the mouse/touch position parameter
    }

    // this is called once our mouseup / touchend event occurs and the drag ends
    onDragEnded(mousePosition) {
        // we'll use it later
        // note the mouse/touch position parameter
    }

    // this is called continuously while the slider is translating
    onTranslation() {
        // we'll use it later
    }

    // this is called once the translation has ended
    onTranslationEnded() {
        // we'll use it later
    }

    // this is called after our slider has been resized
    onSliderResized() {
        // we'll use it later
    }
};

We are now going to take care of the animation and translation part. We’ll use a request animation frame loop to update the slider translation at each tick and set up the according hooks.

  class Slider {

    /*** CONSTRUCTOR ***/

    ...

    /*** HELPERS ***/

    ...

    /*** HOOKS ***/

    ...

    /*** ANIMATIONS ***/

    // this will translate our slider HTML element and set up our hooks
    translateSlider(translation) {
        translation = Math.floor(translation * 100) / 100;

        // should we translate it horizontally or vertically?
        var direction = this.direction === 0 ? "translateX" : "translateY";
        // apply translation
        this.options.element.style.transform = direction + "(" + translation + "px)";

        // if the slider translation is different than the translation to apply
        // that means the slider is still translating
        if(this.translation !== translation) {
            // hook function to execute while we are translating
            this.onTranslation();
        }
        else if(this.isTranslating && !this.isMouseDown) {
            // if those conditions are met, that means the slider is no longer translating
            this.isTranslating = false;

            // hook function to execute after translation has ended
            this.onTranslationEnded();
        }

        // finally set our translation
        this.translation = translation;
    }

    // this is our request animation frame loop where we will translate our slider
    animate() {
        // interpolate values
        var translation = this.lerp(this.translation, this.currentPosition, this.options.easing);

        // apply our translation
        this.translateSlider(translation);

        this.animationFrame = requestAnimationFrame(this.animate.bind(this));
    }

};

In the next part we’re going to write the methods that we’ll bind to the event listeners. This is where we’re going to write our 3 main steps we’ve seen before. We’ll also add a method to be called on resize.

  class Slider {

    /*** CONSTRUCTOR ***/

    ...

    /*** HELPERS ***/

    ...

    /*** HOOKS ***/

    ...

    /*** ANIMATIONS ***/

    ...

    /*** EVENTS ***/

    // on mouse down or touch start
    onMouseDown(e) {
        // start dragging
        this.isMouseDown = true;

        // apply specific styles
        this.options.element.classList.add("dragged");

        // get our touch/mouse start position
        var mousePosition = this.getMousePosition(e);
        // use our slider direction to determine if we need X or Y value
        this.startPosition = mousePosition[this.direction];

        // drag start hook
        this.onDragStarted(mousePosition);
    }

    // on mouse or touch move
    onMouseMove(e) {
        // if we are not dragging, we don't do nothing
        if(!this.isMouseDown) return;

        // get our touch/mouse position
        var mousePosition = this.getMousePosition(e);

        // get our current position
        this.currentPosition = this.endPosition + ((mousePosition[this.direction] - this.startPosition) * this.options.dragSpeed);

        // if we're not hitting the boundaries
        if(this.currentPosition > this.boundaries.min && this.currentPosition < this.boundaries.max) {
            // if we moved that means we have started translating the slider
            this.isTranslating = true;
        }
        else {
            // clamp our current position with boundaries
            this.currentPosition = Math.min(this.currentPosition, this.boundaries.min);
            this.currentPosition = Math.max(this.currentPosition, this.boundaries.max);
        }

        // drag hook
        this.onDrag(mousePosition);
    }

    // on mouse up or touchend
    onMouseUp(e) {
        // we have finished dragging
        this.isMouseDown = false;

        // remove specific styles
        this.options.element.classList.remove("dragged");

        // update our end position
        this.endPosition = this.currentPosition;

        // send our mouse/touch position to our hook
        var mousePosition = this.getMousePosition(e);

        // drag ended hook
        this.onDragEnded(mousePosition);
    }

    // on resize we will need to apply old translation value to new sizes
    onResize(e) {
        // get our old translation ratio
        var ratio = this.translation / this.boundaries.sliderSize;

        // reset boundaries and properties bound to window size
        this.setBoundaries();

        // reset all translations
        this.options.element.style.transform = "tanslate3d(0, 0, 0)";

        // calculate our new translation based on the old translation ratio
        var newTranslation = ratio * this.boundaries.sliderSize;
        // clamp translation to the new boundaries
        newTranslation = Math.min(newTranslation, this.boundaries.min);
        newTranslation = Math.max(newTranslation, this.boundaries.max);

        // apply our new translation
        this.translateSlider(newTranslation);

        // reset current and end positions
        this.currentPosition = newTranslation;
        this.endPosition = newTranslation;

        // call our resize hook
        this.onSliderResized();
    }

};

Alright, we’re almost done with our drag slider. We just need to create a setup function that will register all our event listeners and start the animation loop. We’ll call it inside our constructor. We’ll also be creating a function to cleanly destroy our slider as well, which means removing all event listeners and cancel our request animation frame:

  class Slider {

    /*** CONSTRUCTOR ***/

    constructor(options = {}) {
        // our options
        this.options = {
            // slider state and values
            // the div we are going to translate
            element: options.element || document.getElementById("planes"),
            // easing value, the lower the smoother
            easing: options.easing || 0.1,
            // translation speed
            // 1: will follow the mouse
            // 2: will go twice as fast as the mouse, etc
            dragSpeed: options.dragSpeed || 1,
            // duration of the in animation
            duration: options.duration || 750,
        };

        // if we are currently dragging
        this.isMouseDown = false;
        // if the slider is currently translating
        this.isTranslating = false;

        // current position
        this.currentPosition = 0;
        // drag start position
        this.startPosition = 0;
        // drag end position
        this.endPosition = 0;

        // slider translation
        this.translation = 0;

        this.animationFrame = null;

        // set up the slider
        this.setupSlider();
    }

    /*** HELPERS ***/

    ...

    /*** HOOKS ***/

    ...

    /*** ANIMATIONS ***/

    ...

    /*** EVENTS ***/

    ...

    /*** SET UP AND DESTROY ***/

    // set up our slider
    // init its boundaries, add event listeners and start raf loop
    setupSlider() {
        this.setBoundaries();

        // event listeners

        // mouse events
        window.addEventListener("mousemove", this.onMouseMove.bind(this), {
            passive: true,
        });
        window.addEventListener("mousedown", this.onMouseDown.bind(this));
        window.addEventListener("mouseup", this.onMouseUp.bind(this));

        // touch events
        window.addEventListener("touchmove", this.onMouseMove.bind(this), {
            passive: true,
        });
        window.addEventListener("touchstart", this.onMouseDown.bind(this), {
            passive: true,
        });
        window.addEventListener("touchend", this.onMouseUp.bind(this));

        // resize event
        window.addEventListener("resize", this.onResize.bind(this));

        // launch our request animation frame loop
        this.animate();
    }

    // will be called silently to cleanly remove the slider
    destroySlider() {
        // remove event listeners

        // mouse events
        window.removeEventListener("mousemove", this.onMouseMove, {
            passive: true,
        });
        window.removeEventListener("mousedown", this.onMouseDown);
        window.removeEventListener("mouseup", this.onMouseUp);

        // touch events
        window.removeEventListener("touchmove", this.onMouseMove, {
            passive: true,
        });
        window.removeEventListener("touchstart", this.onMouseDown, {
            passive: true,
        });
        window.removeEventListener("touchend", this.onMouseUp);

        // resize event
        window.removeEventListener("resize", this.onResize);

        // cancel request animation frame
        cancelAnimationFrame(this.animationFrame);
    }

    // call this method publicly to destroy our slider
    destroy() {
        // destroy everything related to the slider
        this.destroySlider();
    }

};

// custom options
var options = {
    easing: 0.075,
    duration: 500,
    dragSpeed: 1.75,
}
// let's go!
var slider = new Slider(options);

And that’s it for now. I hope you liked it so far.

In the next part we’ll be modifying a bit our HTML & CSS and then add the WebGL part by extending our Slider class.


4,839 0 71