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://www.martin-laxenaire.fr/medium/medias/islands-displacement.jpg"; // 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);

!