<div id="canvas"></div>

<div id="content">
  <div class="webGLSlider">
    <div class="wrapper">
      <div class="slide-container">
        <div class="slide" data-name="Patterns and Waves" data-url="#">
          <img src="https://unsplash.it/1920/823?random=1" data-sampler="planeTexture" crossorigin>
        </div>
        <div class="slide" data-name="Cadillac" data-url="#">
          <img src="https://unsplash.it/1920/823?random=2" data-sampler="planeTexture" crossorigin>
        </div>
        <div class="slide" data-name="Laso" data-url="#">
          <img src="https://unsplash.it/1920/823?random=3" data-sampler="planeTexture" crossorigin>
        </div>
        <div class="slide" data-name="WebGL is fun" data-url="#">
          <img src="https://unsplash.it/1920/823?random=4" data-sampler="planeTexture" crossorigin>
        </div>
        <div class="slide" data-name="Zach Saucier" data-url="#">
          <img src="https://unsplash.it/1920/823?random=5" data-sampler="planeTexture" crossorigin>
        </div>
        <div class="slide" data-name="You found me!" data-url="#">
          <img src="https://unsplash.it/1920/823?random=6" data-sampler="planeTexture" crossorigin>
        </div>
      </div>
    </div>

    <h6 class="line-reveal">Recent work</h6>
    <a id="slider-link" class="arrow-link" href="#">
      <h3 id="slider-title" class="slider-heading">Project name</h3>
      <h3 class="slider-heading text-right"><div class="text">View Project</div><svg class="hover-arrow" width="77" height="35" viewBox="0 0 77 35" xmlns="http://www.w3.org/2000/svg"><path d="M58.5295 0L54.8195 3.85L66.5095 14.7H0.189453V19.81H66.5795L54.8195 30.73L58.5295 34.58L76.4495 17.29L58.5295 0Z" fill="#fff"/></svg></h3>
    </a>
  </div>
</div>

<script id="slider-planes-vs" type="x-shader/x-vertex">#version 300 es
  // Determines how much precision for the GPU to use
  #ifdef GL_ES
  precision mediump float;
  #endif
  
  // in = passed in from a data buffer
  // uniforms = passed in from CPU (our program)
  // out = passed from our vertex shader to our fragment shader
  
  // Default mandatory attributes
  in vec3 aVertexPosition;
  in vec2 aTextureCoord;
  
  // Mandatory projection and model view matrices are generated by curtains
  // It will position and size our plane based on its HTML element CSS values
  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  
  // This is generated by curtains based on the sampler name we provided
  // It will be used to map adjust our texture coords so the texture will fit the plane
  uniform mat4 planeTextureMatrix;
  
  // The un-transformed mouse position
  uniform vec2 uMouse;
  
  // Texture coord varying that will be passed to our fragment shader
  out vec2 vTextureCoord;
  // Our transformed mouse position that will be passed to our fragment shader
  out vec2 vMouse;
  
  void main() {
      // Use texture matrix and original texture coords to generate accurate texture coords
      vTextureCoord = (planeTextureMatrix * vec4(aTextureCoord, 0.0, 1.0)).xy;
    
      // Convert from vertex pos to texture pos and apply texture matrix to the mouse position as well 
      vMouse = (planeTextureMatrix * vec4((uMouse + 1.0) * 0.5, 0.0, 1.0)).xy - 0.5;
    
      // Apply our vertex position based on the projection and model view matrices
      gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
  }
</script>

<script id="slider-planes-fs" type="x-shader/x-fragment">#version 300 es
  #ifdef GL_ES
  precision mediump float;
  #endif
  
  // Our texture samplers - Their names match the data-sampler attributes on our image tags
  uniform sampler2D planeTexture; // The image texture
  
  // Our texture coords (on a pixel-by-pixel basis)
  in vec2 vTextureCoord;
  
  in vec2 vMouse; // The mouse position in texture coordinates
  
  out vec4 outputColor; // The output color of each pixel (what was gl_FragColor in WebGL1)

  // FOR MOUSE EFFECTS
  uniform float uRadius; // Radius of pixels to warp/invert
  const float transAmnt = 0.05; // Amount to translate the texture by

  const float PI = 3.1415926535;
  
  const float aspect = 2.33333333; // 21 / 9
  
  // FOR ANTIALIAS
  uniform vec2 uResolution;
  #define R    uResolution
  // This basically gives you antialias for free without the need of a second pass
  // If you don't need antialias in another place, local antiliasing is probably best
  // From here: https://www.shadertoy.com/view/3sjGDh
  // Which is explained here: https://shadertoyunofficial.wordpress.com/
  // (Look for antialias in the search box)
  #define S(v) smoothstep(2.0/R.y, 0.0, v)
  
  void main() {    
    vec2 myUV = vTextureCoord;
    outputColor.a = 1.0;

    // Check if pixel is within the given radius of the mouse
    vec2 diff = myUV - vMouse - 0.5;
    diff.x *= aspect;
    float distance = length(diff);

    // Create the fish-eye effect
    if (distance <= uRadius) {
      float scale = (1.0 - cos(distance/uRadius * PI * 0.5));
      myUV = vMouse + normalize(diff) * uRadius * scale + 0.5;
    }

    // Translate the texture
    myUV += -vMouse * transAmnt;

    vec3 tex = texture(planeTexture, myUV).rgb;

    // Antialiasing
    vec3 inverted = vec3(1.0 - tex.r, 1.0 - tex.g, 1.0 - tex.b);
    if(uRadius > 0.0) {
      outputColor.rgb = mix( inverted, tex, S(distance - uRadius));
    } else {
      outputColor.rgb = inverted.rgb;
    }
  }
</script>
* {
  box-sizing: border-box;
}

html {
  font-size: 25px;
}

body {
  background: #1d1d1d;
  font-family: Asap, sans-serif;
}

#canvas {
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  height: 100vh;
}
#content {
  position: relative;
  z-index: 1;
}

.wrapper {
  position: relative;
  overflow: hidden;
}

.slide-container {
  position: relative;
}
.slide-container::before {
  display: block;
  content: "";
  width: 100%;
  padding-top: 42.857%; /* 9 / 21 */
}

.slide {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
}
.slide img {
  width: 100%;
}


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


.webGLSlider {
  color: white;
}
.slider-heading {
  text-transform: uppercase;
  margin: 0;
}

.webGLSlider a {
  color: inherit;
  text-decoration: none;
}

.text-right {
  text-align: right;
}
.text-right .text {
  display: inline-block;
  vertical-align: middle;
  margin-right: 2rem;
}
.hover-arrow {
  vertical-align: middle;
}







.split-child {
  display: inline-block;
}
.split-parent {
  overflow: hidden;
}



.webGLSlider img {
  display: none;
}
.no-curtains img:not(.texture) {
  display: block;
}
View Compiled
// Save references to elements that we will use
const wrapper = document.querySelector(".webGLSlider .wrapper");
const slideContainer = document.querySelector(".webGLSlider .slide-container");
const slides = gsap.utils.toArray(".webGLSlider .slide");
const proxy = document.createElement("div");
const webGLSlider = document.querySelector('.webGLSlider');
let sliderTitle = document.querySelector('#slider-title');
const sliderLink = document.querySelector('#slider-link');

const numSlides = slides.length;
const wrapIndex = gsap.utils.wrap(0, numSlides);
const gap = 0;
let lastIndex = 0; // Used in resize and to change out headings
let firstRun = true;

// These are set in the resize function
let slideHeight;
let slideWidth;
let wrapWidth;
let wrapVal;
let animation;
let draggable;

// Variables related to the WebGL functionality
const planes = [];
const maxVelocity = 10000;
const halfMaxVelocity = maxVelocity / 2;

//////////////////////////////
// Heading change functions //
//////////////////////////////
function setupHeadingAnim() {
  // Split it twice to be able to reveal it from hidden overflow
  webGLSlider.childSplit = new SplitText('.slider-heading', {
    type: 'lines',
    linesClass: 'split-child'
  });
  webGLSlider.parentSplit = new SplitText('.slider-heading', {
    type: 'lines',
    linesClass: 'split-parent'
  });
  
  // The text reveal animation
  webGLSlider.anim = gsap.from(webGLSlider.childSplit.lines, {
    paused: true,
    yPercent: 100, 
    stagger: 0.2
  });
  if(firstRun) {
    webGLSlider.anim.play();
    firstRun = false;
  }
}

// Check to see if the index should be changed and set up the heading changes
function checkIndex(endX) {
  // Get the new index
  endX = -endX || 0;
  const newIndex = wrapIndex(endX / (slideWidth + gap));
  
  // Only do stuff if it's a new index
  if(typeof webGLSlider.anim === "undefined" || lastIndex !== newIndex) {
    // Undo pre anim
    if(webGLSlider.anim) {
      webGLSlider.anim.progress(1).kill();
      webGLSlider.parentSplit.revert();
      webGLSlider.childSplit.revert();
    }
    
    // Update index, text, and link
    sliderTitle = document.querySelector('#slider-title');
    lastIndex = newIndex;
    const dataset = slides[newIndex].dataset;
    sliderTitle.innerText = dataset.name;
    sliderLink.href = dataset.url;
    
    // Create new anim
    setupHeadingAnim();
  
    setupArrowLinks();
  }
}

//////////////////////////////////
// Create and handle the slider //
//////////////////////////////////
function resize() {
  // Get the new values
  slideWidth  = gsap.getProperty('.slide', 'width');
  slideHeight = gsap.getProperty('.slide', 'height');
  
  const widthUnit = (slideWidth + gap);
  wrapWidth = numSlides * widthUnit;
  wrapVal = gsap.utils.wrap(0, wrapWidth);
  
  // Setup our slider with the new values
  const pxOffset = lastIndex !== numSlides - 1 ? lastIndex * -widthUnit : widthUnit;
  gsap.set(slideContainer, { left: -widthUnit });
  gsap.set(proxy, { x: pxOffset });
  
  // The animation that's used to do the sliding
  animation = gsap.fromTo(slides, {
    x: i => wrapVal(i * widthUnit)
  }, {
    duration: 1,
    x: `+=${wrapWidth}`,
    ease: "none",
    paused: true,
    // This creates the infinite looping
    modifiers: {
      x: function(x, target) {
        return `${parseInt(x) % wrapWidth}px`;
      }
    }
  })
  // Set progress to correct value
  .progress(1 - lastIndex / numSlides);
  
  // Kill off the old draggable instance
  if(draggable) {
    draggable.kill();
  }
  
  // Recreate the draggable with the new values
  draggable = Draggable.create(proxy, {
    type: "x",
    trigger: ".wrapper",
    inertia: true,
    snap: { 
      x: (x) => {
        return Math.round(x / widthUnit) * widthUnit;
      } 
    },
    
    // Our event listeners
    onDragStart: () => webGLSlider.anim.timeScale(-3),
    onDrag: updateProgress,
    onRelease: function() { checkIndex(this.endX) }, // Update our index (and headings if need be)
    onThrowUpdate: updateProgress,
    onThrowComplete: () => webGLSlider.anim.play()
  })[0];
  
  // Update the resolution in the WebGL planes
  planes.forEach(plane => plane.uniforms.resolution.value = [innerWidth, innerHeight]);
}

// Update the slider along with the necessary WebGL variables
function updateProgress() {
  // Update the actual slider
  animation.progress(wrapVal(this.x) / wrapWidth);
  
  // Update the WebGL slider planes
  planes.forEach(plane => plane.updatePosition());
  
  // Update the WebGL "mouse"
  updateWebGLMouse(0);
}


//////////////////////////////////
// Keep mouse synced with WebGL //
//////////////////////////////////
const mouse = new Vec2(0, 0);
function addMouseListeners() {
  if ("ontouchstart" in window) {
    wrapper.addEventListener("touchstart", updateMouse, false);
    wrapper.addEventListener("touchmove", updateMouse, false);
    wrapper.addEventListener("blur", mouseOut, false);
  } else {
    wrapper.addEventListener("mousemove", updateMouse, false);
    wrapper.addEventListener("mouseleave", mouseOut, false);
  }
}

// Update the stored mouse position along with WebGL "mouse"
function updateMouse(e) {
  radiusAnim.play();
  
  if (e.changedTouches && e.changedTouches.length) {
    e.x = e.changedTouches[0].pageX;
    e.y = e.changedTouches[0].pageY;
  }
  if (e.x === undefined) {
    e.x = e.pageX;
    e.y = e.pageY;
  }
  
  mouse.x = e.x;
  mouse.y = e.y;
  
  
  updateWebGLMouse();
}

// Updates the mouse position for all planes
function updateWebGLMouse(dur) {
  // update the planes mouse position uniforms
  planes.forEach((plane, i) => {
    const webglMousePos = plane.mouseToPlaneCoords(mouse);
    updatePlaneMouse(plane, webglMousePos, dur);
  });
}

// Updates the mouse position for the given plane
function updatePlaneMouse(plane, endPos = new Vec2(0, 0), dur = 0.1) {
  gsap.to(plane.uniforms.mouse.value, {
    x: endPos.x,
    y: endPos.y,
    duration: dur,
    overwrite: true,
  });
}

// When the mouse leaves the slider, animate the WebGL "mouse" to the center of slider
function mouseOut(e) {
  planes.forEach((plane, i) => updatePlaneMouse(plane, new Vec2(0, 0), 1) );
  
  radiusAnim.reverse();
}

////////////////////////////////
// Radius hover functionality //
////////////////////////////////
const radius = { val: 0.1 };
const radiusAnim = gsap.from(radius, { 
  val: 0, 
  duration: 0.3, 
  paused: true,
  onUpdate: updateRadius
});
function updateRadius() {
  planes.forEach((plane, i) => {
    plane.uniforms.radius.value = radius.val;
  });
}


//////////////////////
// Arrow hover code //
//////////////////////
const arrowLinks = document.querySelectorAll(".arrow-link");
const playLinkAnim = (e) => e.target.anim.play();
const reverseLinkAnim = (e) => e.target.anim.reverse();
function setupArrowLinks() {
  arrowLinks.forEach(link => {
    if(link.anim) {
      link.anim.kill();
      link.removeEventListener("mouseenter", playLinkAnim);
      link.removeEventListener("mouseleave", reverseLinkAnim);
    }

    link.svg = link.querySelector("svg");
    link.path = link.svg.querySelector("path");
    link.anim = gsap.timeline({
      paused: true,
      defaults: {
        duration: 0.3
      }
    })
      .from(link.svg, {
      attr: { 
        width: 44,
        height: 36,
        viewBox: "0 0 44 36"
      }
    })
      .fromTo(link.path, {
      attr: { d: "M25.5295 0.48999L21.8195 4.33999L33.5095 15.19H0.189453V20.3H33.5795L21.8195 31.22L25.5295 35.07L43.4495 17.78L25.5295 0.48999Z" }
    }, {
      attr: { d: "M58.5295 0L54.8195 3.85L66.5095 14.7H0.189453V19.81H66.5795L54.8195 30.73L58.5295 34.58L76.4495 17.29L58.5295 0Z" }
    }, 0)

    link.addEventListener("mouseenter", playLinkAnim);
    link.addEventListener("mouseleave", reverseLinkAnim);
  });
}


////////////////
// Init stuff //
////////////////
window.addEventListener("load", e => {
  window.addEventListener("resize", resize);
  resize();

  checkIndex();
  
  // Create a new curtains instance
  const curtains = new Curtains({ container: "canvas", autoRender: false });
  // Use a single rAF for both GSAP and Curtains
  function renderScene() {
    curtains.render();
  }
  gsap.ticker.add(renderScene);
  // Create a curtains plane for each slide
  const planeElements = document.querySelectorAll(".slide");
  
  // Params passed to the curtains instance
  const params = {
    vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
    fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
    
    // The variables that we're going to be animating to update our WebGL state
    uniforms: {
      // For the cursor effects
      mouse: { 
        name: "uMouse", // The shader variable name
        type: "2f",     // The type for the variable - https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html
        value: mouse    // The initial value to use
      },
      radius: { 
        name: "uRadius",
        type: "1f",
        value: radius.val
      },
      
      // For the antialiasing
      resolution: { 
        name: "uResolution",
        type: "2f", 
        value: [innerWidth, innerHeight] 
      }
    },
  };
  
  // Create a new plane for each slider
  planeElements.forEach((planeEl, i) => {
    const plane = new Plane(curtains, planeEl, params);

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

      // onReady is called once our plane is ready and all its texture have been created
      plane.onLoading(function(texture) {
        // Scale up the texture (for the translation effect)
        texture.setScale(new Vec2(1.2, 1.2));
      }).onReady(function() {
        // One could use "this" instead of "plane" here 

        // Add a "loaded" class to display image container
        plane.htmlElement.closest(".slide").classList.add("loaded");
      });
    }
  });
  
  // Add our mouse listeners to our slider
  addMouseListeners();
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.5.1/gsap.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.5.1/Draggable.min.js
  3. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/InertiaPlugin.min.js
  4. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/SplitText3.min.js
  5. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.5.1/ScrollTrigger.min.js
  6. https://cdn.jsdelivr.net/npm/curtainsjs@7.2.1/dist/curtains.umd.min.js