DEV Community

Cover image for Project Moon - GSAP Cursor Animation + Navigation Menu + WebGl Slider
Helitha Rupasinghe
Helitha Rupasinghe

Posted on • Edited on

Project Moon - GSAP Cursor Animation + Navigation Menu + WebGl Slider

Purpose Of Project

My JavaScript Testing ground for the foreseeable future.

Getting started

Go ahead and initialise our new project using the CodePen playground or setup your own project on Visual Studio Code with the following file structure under your src folder.

Project Moon Starter Files
  |- Assets
    |- CSS
      |- style.css 
    |- JS
      |- main.js
  |- /src
    |- index.html
Enter fullscreen mode Exit fullscreen mode

Part 1: HTML

Start by editing your index.html and replace it with the following code.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Project Moon | Navigation + Slider</title>
    <link href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAwAEAAOADAADn8wAA8+cAAPHHAAD5zwAA+I8AAPyfAAD8HwAA/j8AAP4/AAD/fwAA//8AAP//AAD//wAA" rel="icon" type="image/x-icon" />

    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css" rel="stylesheet">
    <link rel="stylesheet" href="assets/css/style.css">
  </head>
  <body>
    <!--Cursor-->
    <div>
        <div class="cursor"></div>
        <div class="cursorDot"></div>
    </div>
    <main>

<!-- Start Navigation -->
<header id="header">
    <div class="header-row">
      <div class="brand-logo">
        <a class="brand-text cursor-scale small" href="#">Project Moon</a>
      </div>
     <div class="main cursor-scale small">
      <div class="bars"></div>
    </div>
    <div class="menu">
      <div class="navBefore"></div>
      <div class="nav">
        <ul class="navigation">
          <li><a href="#" class="cursor-scale">Home</a></li>
          <li><a href="#" class="cursor-scale">About</a></li>
          <li><a href="#" class="cursor-scale">Work</a></li>
          <li><a href="#" class="cursor-scale">Contact</a></li>
          <li><a target="_blank" href="#">EN</a></li>
        </ul>
      </div>
    </div>
    </div>
  </header>
  <section id="content">
    <div id="planes">
      <div class="plane-wrapper">
        <span class="plane-title">JAPAN</span>
        <div class="plane">
          <img src="https://images.unsplash.com/photo-1545569341-9eb8b30979d9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NHx8amFwYW58ZW58MHx8MHx8&auto=format&fit=crop&w=600&q=60" alt="Photo by Su San Lee on Unsplash" data-sampler="planeTexture" crossorigin />
        </div>
      </div>
          <div class="plane-wrapper">
        <span class="plane-title">AUSTRALIA</span>
        <div class="plane">
          <img src="https://images.unsplash.com/photo-1506973035872-a4ec16b8e8d9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8YXVzdHJhbGlhfGVufDB8fDB8fA%3D%3D&auto=format&fit=crop&w=600&q=60" alt="Photo by Dan Freeman on Unsplash" data-sampler="planeTexture" crossorigin />
        </div>
      </div>
          <div class="plane-wrapper">
        <span class="plane-title">USA</span>
        <div class="plane">
          <img src="https://images.unsplash.com/photo-1591437009328-f4499ddd7eb0?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8MTV8fHRleGFzJTIwZmxhZ3xlbnwwfHwwfHw%3D&auto=format&fit=crop&w=600&q=60" alt="Photo by Aaron Burden on Unsplash" data-sampler="planeTexture" crossorigin />
        </div>
      </div>
          <div class="plane-wrapper">
        <span class="plane-title">UK</span>
        <div class="plane">
          <img src="https://images.unsplash.com/photo-1513635269975-59663e0ac1ad?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8Mnx8ZW5nbGFuZHxlbnwwfHwwfHw%3D&auto=format&fit=crop&w=600&q=60" alt="Photo by Ben Davies on Unsplash" data-sampler="planeTexture" crossorigin />
        </div>
      </div>
    </div>
  </section>
    </main>
          <!-- GSAP CDN -->
         <script src="https://unpkg.co/gsap@3/dist/gsap.min.js"></script>
          <!-- CurtainJS CDN -->
         <script src="https://www.curtainsjs.com/build/curtains.min.js"></script>
          <!-- AnimeJS CDN -->
         <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/2.2.0/anime.min.js"></script>
         <!-- Core theme JS-->
         <script src="assets/js/main.js"></script>
        </body>
  </html>
Enter fullscreen mode Exit fullscreen mode

Part 2: CSS

Next step is to add the following styles and complete our style.css file.

@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&family=Orbitron:wght@400;500;600;700;800;900&display=swap');

/* Base reset */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
  }


  /*Body styling*/
body {
    font-family: "Orbitron", sans-serif;
    letter-spacing: 2px;
    line-height: 2;
    background-color: black;
    -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
  }

  /*Nav Styles*/
#header{
    position: fixed;
    z-index: 10;
    left: 0;
    top: 0;
    width: 100%;
    height: 100vh;
  }

  .header-row{
    padding: 0px 15px;
    display: flex;
    justify-content: space-between;
  }

  /*Brand Logo + text*/
.brand-logo{
    line-height: 100px;
    float: left;
    text-transform: uppercase;
  }

  .brand-text {
    font-size: 2em;
    line-height: 80px;
    font-family: "Montserrat", cursive;
    font-weight: 500;
    text-decoration-line: none;
    color: #fff;
  }

  /*Hamburger Styles*/

  .main .bars {
    position: fixed;
    height: 30px;
    width: 50px;
    top: 5%;
    right: 5%;
    display: flex;
    flex-direction: column;
    align-items: center;
    z-index: 9999999999;
    cursor: pointer;
  }

  .main .bars::before {
    position: absolute;
    content: "";
    height: 2px;
    width: 90%;
    background: #fff;
    transition: 0.3s linear;
  }

  .main .bars.active::before {
    transform: rotate(45deg);
    width: 50%;
    top: 5%;
    background: #000;
  }

  .main .bars::after {
    position: absolute;
    content: "";
    height: 2px;
    width: 90%;
    background: #fff;
    top: 35%;
    transition: 0.3s linear;
  }

  .main .bars.active::after {
    transform: rotate(-45deg);
    width: 50%;
    top: 5%;
    background: #000;
  }

  /*Nav Menu*/
  .menu {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 999999999;
    overflow: hidden;
    display: none;
  }

  .menu .navBefore {
    position: absolute;
    margin-left: 100%;
    width: 100%;
    height: 100%;
    background: #017bf5;
  }

  .menu .nav {
    position: relative;
    margin-left: 100%;
    width: 100%;
    height: 100%;
    background: #fff;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .menu .nav ul {
    opacity: 0;
  }
  .menu .nav ul li {
    list-style: none;
  }
  .menu .nav ul li a {
    position: relative;
    font-size: 4.5rem;
    text-decoration: none;
    text-align: center;
    color: #666;
  }

  .menu .nav ul li a:hover,
  .menu .nav ul li.active a {
    color: #000;
    text-decoration-line: line-through;
  }

  /* Cursor Styles*/
.cursor{
    position: absolute;
    width: 40px;
    height: 40px;
    margin-left: -20px;
    margin-top: -20px;
    border-radius: 50%;
    border: 3px solid whitesmoke;
    transform: translate(-50%, -50%);
    transition: transform .2s ease;
    pointer-events: none;
    backdrop-filter: grayscale(1);
    z-index: 1000;
  }

  .cursorDot{
    position: absolute;
    width: 4px;
    height: 4px;
    margin-left: -20px;
    margin-top: -20px;
    border-radius: 50%;
    background-color: whitesmoke;
    transform: translate(-50%, -50%);
    transition: 0.1s;
    pointer-events: none;
    z-index: 1000;
  }

  .grow, .grow-small{
    transform: scale(4);
    background: white;
    mix-blend-mode: difference; 
    border: none;
  }

  .grow-small{
    transform: scale(2);
  }

  /*Drag Slider*/
  #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;
}

.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: #fff;
    text-stroke: 1px white;
    -webkit-text-stroke: 1px white;

    opacity: 0;

    transition: color 0.5s, opacity 0.5s;
}

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

.plane-wrapper.loaded .plane-title, .no-curtains .plane-title {
    opacity: 1;
}

.plane {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
}

.plane img {
    /* hide original images if there's no WebGL error */
/*         display: none; */
    /* 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;
}
Enter fullscreen mode Exit fullscreen mode

Part 3: JavaScript

Now we can implement our JavaScript logic to our WebGL setup like so.

console.clear();

let cursor = document.querySelector('.cursor');
let cursorDot = document.querySelector(".cursorDot");
let cursorScale = document.querySelectorAll('.cursor-scale'); 
let mouseX = 0;
let mouseY = 0;

gsap.to({}, 0.016, {
  repeat: -1,
  onRepeat: function(){
    gsap.set(cursor, {
      css: {
        left: mouseX,
        top: mouseY,
      }
    });
     gsap.set(cursorDot, {
      css: {
        left: mouseX,
        top: mouseY
      }
    });
  }
});

window.addEventListener("mousemove", (e) => {
  mouseX = e.clientX;
  mouseY = e.clientY;
});

cursorScale.forEach((link) => {
  link.addEventListener("mousemove", () => {
    cursor.classList.add("grow");
    if (link.classList.contains("small")) {
      cursor.classList.remove("grow");
      cursor.classList.add("grow-small");
    }
  });

  link.addEventListener("mouseleave", () => {
    cursor.classList.remove("grow");
    cursor.classList.remove("grow-small");
  });
});

window.onload = function () {
  const bars = document.querySelector(".bars");
  const menu = document.querySelector(".menu");
  bars.addEventListener("click", function (e) {
    this.classList.toggle("active");
    if (this.classList.contains("active")) {
      gsap.to(".menu", {
        duration: 0.1,
        display: "flex",
        ease: "expo.in"
      });
      gsap.to(".navBefore", {
        duration: 0.5,
        marginLeft: "0",
        ease: "expo.in"
      });
      gsap.to(".nav", {
        duration: 0.8,
        marginLeft: "0",
        delay: 0.3,
        ease: "expo.in"
      });
      gsap.to(".navigation", {
        duration: 1,
        opacity: "1",
        delay: 0.8,
        ease: "expo.in"
      });
    } else {
      gsap.to(".navigation", {
        duration: 0.2,
        opacity: "0",
        ease: "expo.in"
      });
      gsap.to(".nav", {
        duration: 1,
        marginLeft: "100%",
        delay: 0.3,
        ease: "expo.in"
      });
      gsap.to(".navBefore", {
        duration: 1,
        marginLeft: "100%",
        delay: 0.5,
        ease: "expo.in"
      });
      gsap.to(".menu", {
        duration: 1,
        display: "none",
        delay: 1,
        ease: "expo.in"
      });
    }
  });
};

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 ***/

    // 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 started
    onDragStarted(mousePosition) {
    }

    // this is called while we are currently dragging the slider
    onDrag(mousePosition) {
    }

    // this is called once our mouseup / touchend event occurs and the drag started
    onDragEnded(mousePosition) {
    }

    // this is called continuously while the slider is translating
    onTranslation() {
    }

    // this is called once the translation has ended
    onTranslationEnded() {
    }

    // this is called before our slider has been resized
    onBeforeResize() {
    }

    // this is called after our slider has been resized
    onSliderResized() {
    }

    /*** 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));
    }

    /*** 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) {
        this.onBeforeResize();

        // 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();
    }

    /*** 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();
    }

};

class WebGLSlider extends Slider {

    /*** CONSTRUCTOR ***/

    constructor(options) {
        super(options);

        // tweening
        this.animation = null;
        // value from 0 to 1 to pass as uniform to the WebGL
        // will be tweened on mousedown / touchstart and mouseup / touchend events
        this.effect = 0;

        // our WebGL variables
        this.curtains = null;
        this.planes = [];
        // we will keep track of the previous translation values on resize
        this.previousTranslation = {
            x: 0,
            y: 0,
        };
        this.shaderPass = null;

        // set up the WebGL part
        this.setupWebGL();
    }

    /*** WEBGL INIT ***/

    // set up WebGL context and scene
    setupWebGL() {
        // set up our WebGL context, append the canvas to our wrapper and create a requestAnimationFrame loop
        // the canvas will be our scene containing all our planes
        // this is the scene we will post process
        this.curtains = new Curtains({
          container: "canvas"
        });

        this.curtains.onError(function() {
            // onError handles all errors during WebGL context initialization or plane creation
            // we will add a class to the document body to display original images (see CSS)
            document.body.classList.add("no-curtains");
        });

        // planes and shader pass
        this.setupPlanes();
        this.setupShaderPass();
    }

    /*** PLANES CREATION ***/

    setupPlanes() {
        // Planes

        // each plane is bound to a HTML element to copy its size and position
        // in this case this will be the slider inner items
        // it will automatically create a WebGL texture for each image, canvas and video child of that element
        var planeElements = document.getElementsByClassName("plane");

        // our planes params
        // we just pass our shaders tag ID and a uniform to animate opacity on load
        var params = {
            vertexShaderID: "slider-planes-vs",
            fragmentShaderID: "slider-planes-fs",
            uniforms: {
                opacity: {
                    name: "uOpacity", // variable name inside our shaders
                    type: "1f", // this means our uniform is a float
                    value: 0,
                },
            },
        };

        // add all our planes and handle them
        for(var i = 0; i < planeElements.length; i++) {
            // addPlane method adds a plane to our WebGL scene
            // takes 2 params: our HTML referent element and the params set above
            // it returns a Plane class object if creation is successful, false otherwise
            var plane = this.curtains.addPlane(planeElements[i], params);

            // if our plane has been successfully created
            if(plane) {
                // push it into our planes array
                this.planes.push(plane);

                // onReady is called once our plane is ready and all its texture have been created
                plane.onReady(function() {
                    // inside our onReady function scope, this represents our plane
                    var currentPlane = this;

                    // add a "loaded" class to display the title
                    currentPlane.htmlElement.closest(".plane-wrapper").classList.add("loaded");

                    // animate plane opacity once they are loaded
                    var opacity = {
                        value: 0,
                    };

                    anime({
                        targets: opacity,
                        value: 1,
                        easing: "linear",
                        duration: 750,
                        update: function() {
                            // continualy increase opacity from 0 to 1
                            currentPlane.uniforms.opacity.value = opacity.value;
                        },
                    });
                });
            }
        }
    }

    /*** SHADER PASS CREATION ***/

    setupShaderPass() {
        // Shader pass
        // we will post process our scene
        // that means we will apply shaders to our whole scene

        // like for regular planes we will need params
        // they will contain vertex and fragment shaders ID and our uniforms
        var shaderPassParams = {
            vertexShaderID: "distortion-vs",
            fragmentShaderID: "distortion-fs",
            uniforms: {
                // apply the whole effect
                // 0: no effect
                // 1: full effect
                dragEffect: {
                    name: "uDragEffect", // variable name inside our shaders
                    type: "1f", // this means our uniform is a float
                    value: 0,
                },
                // our mouse position (in WebGL clip space coordinates)
                mousePos: {
                    name: "uMousePos",
                    type: "2f", // this means our uniform is a length 2 array of floats
                    value: [0, 0],
                },
                // direction of our slider
                // 0: horizontal drag
                // 1: vertical drag
                direction: {
                    name: "uDirection",
                    type: "1f",
                    value: this.direction,
                },
                // the background color when effect is applied
                bgColor: {
                    name: "uBgColor",
                    type: "3f", // this means our uniform is a length 3 array of floats
                    value: [3, 135, 154], // rgb values
                },
                // our displacement texture offset
                offset: {
                    name: "uOffset",
                    type: "2f",
                    value: [0, 0],
                },
            },
        };

        // addShaderPass adds a shader pass (Frame Buffer Object) to our WebGL scene
        // returns a ShaderPass class object if successful, false otherwise
        this.shaderPass = this.curtains.addShaderPass(shaderPassParams);

        // if our shader pass has been successfully created
        if(this.shaderPass) {
            // we will add our displacement map texture

            // first we load a new image
            var image = new Image();
            image.src = "https://images.unsplash.com/photo-1545569341-9eb8b30979d9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NHx8amFwYW58ZW58MHx8MHx8&auto=format&fit=crop&w=600&q=60";
            // then we set its data-sampler attribute to use in fragment shader
            image.setAttribute("data-sampler", "displacementTexture");
            // finally we load it into our shader pass via the loadImage method
            this.shaderPass.loadImage(image);

            var self = this;

            // onRender is called at each requestAnimationFrame call
            this.shaderPass.onRender(function() {
                // we will continuously offset our displacement texture on secondary axis
                var secondaryDirection = self.direction === 0 ? 1 : 0;
                self.shaderPass.uniforms.offset.value[secondaryDirection] = self.shaderPass.uniforms.offset.value[secondaryDirection] + 1;
            });
        }
    }


    /*** HELPER ***/

    // this will update our shader pass mouse position uniform
    updateMousePosUniform(mousePosition) {
        // if our shader pass exists, update the mouse position uniform
        if(this.shaderPass) {
            // mouseToPlaneCoords converts window coordinates to WebGL clip space
            var relativeMousePos = this.shaderPass.mouseToPlaneCoords(mousePosition[0], mousePosition[1]);
            this.shaderPass.uniforms.mousePos.value = [relativeMousePos.x, relativeMousePos.y];
        }
    }

    /*** HOOKS ***/

    // this is called once our mousedown / touchstart event occurs and the drag started
    onDragStarted(mousePosition) {
        // pause and remove previous animation
        if(this.animation) this.animation.pause();
        anime.remove(slider);

        // get a ref
        var self = this;

        // animate our mouse down effect
        this.animation = anime({
            targets: self,
            effect: 1,
            easing: 'easeOutCubic',
            duration: self.options.duration,
            update: function() {
                if(self.shaderPass) {
                    // update our shader pass uniforms
                    self.shaderPass.uniforms.dragEffect.value = self.effect;
                }
            }
        });

        // enableDrawing to re-enable drawing again if we disabled it earlier
        this.curtains.enableDrawing();

        // update our shader pass mouse position uniform
        this.updateMousePosUniform(mousePosition);
    }

    // this is called while we are currently dragging the slider
    onDrag(mousePosition) {
        // update our shader pass mouse position uniform
        this.updateMousePosUniform(mousePosition);
    }

    // this is called once our mouseup / touchend event occurs and the drag started
    onDragEnded(mousePosition) {
        // calculate duration based on easing
        var duration = 100 / this.options.easing;
        var easing = 'linear';

        // if there's no movement just tween the shader pass effect
        if(Math.abs(this.translation - this.currentPosition) < 5) {
            easing = 'easeOutCubic';
            duration = this.options.duration;
        }

        // pause remove previous animation
        if(this.animation) this.animation.pause();
        anime.remove(slider);

        // get a ref
        var self = this;

        this.animation = anime({
            targets: self,
            effect: 0,
            easing: easing,
            duration: duration,
            update: function() {
                if(self.shaderPass) {
                    // update drag effect
                    self.shaderPass.uniforms.dragEffect.value = self.effect;
                }
            }
        });

        // update our shader pass mouse position uniform
        this.updateMousePosUniform(mousePosition);
    }

    // this is called continuously while the slider is translating
    onTranslation() {
        // get our slider translation and take our previous translation into account
        var planeTranslation = {
            x: this.direction === 0 ? this.translation - this.previousTranslation.x : 0,
            y: this.direction === 1 ? this.translation - this.previousTranslation.y : 0,
        };

        // keep our WebGL planes position in sync with their HTML elements
        for(var i = 0; i < this.planes.length; i++) {
            // in the previous CodePen we were using updatePosition the method which handles positioning automatically
            // however this method internally calls getBoundingClientRect() which causes a reflow and therefore impacts performance
            // so we will position our planes manually with setRelativePosition instead, which does not trigger a layout repaint call
          this.planes[i].setRelativePosition(planeTranslation.x, planeTranslation.y);
        }

        // shader pass displacement texture offset
        if(this.shaderPass) {
            // we will offset our displacement effect on main axis so it follows the drag
            var offset = ((this.direction - 1) * 2 + 1) * this.translation / this.boundaries.referentSize;

            this.shaderPass.uniforms.offset.value[this.direction] = offset;
        }
    }

    // this is called once the translation has ended
    onTranslationEnded() {
        // we will stop rendering our WebGL until next drag occurs
        if(this.curtains) {
            this.curtains.disableDrawing();
        }
    }

    // this is called after our slider has been resized
    onSliderResized() {
        // we need to update our previous translation value
        this.previousTranslation = {
            x: this.direction === 0 ? this.translation : 0,
            y: this.direction === 1 ? this.translation : 0,
        };

        // reset our slides relative positions
        // because during the resize their positions has already been updated internally
        for(var i = 0; i < this.planes.length; i++) {
            this.planes[i].setRelativePosition(0, 0);
        }

        // update our direction uniform
        if(this.shaderPass) {
            // update direction
            this.shaderPass.uniforms.direction.value = this.direction;
        }
    }


    /*** DESTROY ***/

    // destroy all WebGL related things
    destroyWebGL() {
        // if you want to totally remove the WebGL context uncomment next line
        // and remove what's after
        //this.curtains.dispose();

        // if you want to only remove planes and shader pass and keep the context available
        // that way you could re init the WebGL later to display the slider again
        if(this.shaderPass) {
            this.curtains.removeShaderPass(this.shaderPass);
        }

        for(var i = 0; i < this.planes.length; i++) {
            this.curtains.removePlane(this.planes[i]);
        }
    }

    // call this method publicly to destroy our slider and the WebGL part
    // override the destroy method of the Slider class
    destroy() {
        // destroy everything related to WebGL and the slider
        this.destroyWebGL();
        this.destroySlider();
    }
}

// custom options
var options = {
    easing: 0.1,
    duration: 500,
    dragSpeed: 1.75,
}

// let's go!
var slider = new WebGLSlider(options);
Enter fullscreen mode Exit fullscreen mode

Recap

If you followed along then you should have completed the project and finished off your WebGL project.

Now if you made it this far, then I am linking the code to my GitHub for you to fork or clone and then the job's done.

License: 📝

This project is under the MIT License (MIT). See the LICENSE for more information.

Contributions

Contributions are always welcome...

🔹 Fork the repository
🔹 Improve current program by
🔹 improving functionality
🔹 adding a new feature
🔹 bug fixes
🔹 Push your work and Create a Pull Request

Useful Resources

https://cdnjs.com/
https://www.curtainsjs.com/build/curtains.min.js
https://cdnjs.com/libraries/gsap
https://cdnjs.cloudflare.com/ajax/libs/animejs/2.2.0/anime.min.js

Top comments (2)

Collapse
 
neoprint3d profile image
Drew Ronsman

Nice project,but it would be nice if u added a code pen or something

Collapse
 
hr21don profile image
Helitha Rupasinghe

I posted my github repo under the recap section. Maybe you missed the link?