<div id="app" 
     @mousemove="drag"
     @touchmove="drag"
     @mouseup="stopDrag"
     @touchend="stopDrag"
     class="flex items-center justify-center bg-black w-screen h-screen">
  <div class="mobile-container relative overflow-hidden">
    
    <!--  image below   -->
    <img class="absolute h-full z-0"
         :style="prevImageStyle"
         :src="previousImage">
    
    <!--  interactive image   -->
    <img class="absolute h-full z-10"
         @mousedown.prevent="startDrag"
         @touchstart="startDrag" 
         :style="currentImageStyle"
         :src="currentImage">
    
    <!--  image above   -->
    <img class="absolute h-full z-20"
         :style="nextImageStyle"
         :src="nextImage">
    
    <pre class="fixed bottom-0 left-0 p-3 text-white z-50 bg-gray-800 opacity-75 pointer-events-none">
dragging: {{ dragging }}
animating: {{ animating }}
imagesIndexes: [{{previousImageIndex}}] [{{currentImageIndex}}] [{{nextImageIndex}}]
cursorStartX: {{ cursorStartX }}
cursorCurrentX: {{ cursorCurrentX }}
diffX: {{ diffX }}
currentImagePosition: {{currentImagePosition}}
nextImagePosition: {{nextImagePosition}}</pre>
  </div>
  
</div>
.mobile-container {
  width: 320px;
  height: 568px;
}
const images = [
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684546/wotw-013/nature-1.jpg',
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684546/wotw-013/nature-2.jpg',
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684526/wotw-013/nature-3.jpg',
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684544/wotw-013/nature-4.jpg',
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684520/wotw-013/nature-5.jpg',
  'https://res.cloudinary.com/ederchrono/image/upload/v1556684527/wotw-013/nature-6.jpg'
]

const DEVICE_WIDTH = 320
const HALF_WIDTH = DEVICE_WIDTH / 2
const DRAGGING_SPEED = 1.2
const MAX_BLUR = 8

const getCursorX = (event) => {
  if(event.touches && event.touches.length) {
    // touch
    return event.touches[0].pageX        
  }
  
  if(event.pageX && event.pageY) {
    // mouse
    return event.pageX        
  }
  
  return 0
}

const clampPosition = (position) => {
  // constrain image to be between 0 and device width
  return Math.max(Math.min(position, DEVICE_WIDTH), 0)
}

const calculateBlur = (position) => {
  return MAX_BLUR * (1 - (position / DEVICE_WIDTH));
}

new Vue({
  el: '#app',
  data: {
    dragging: false,
    animating: false,
    cursorStartX: 0,
    cursorCurrentX: 0,
    currentImageIndex: 0,
    currentImageAnimatedX: 0,
    nextImageAnimatedX: DEVICE_WIDTH
  },
  methods: {
    startDrag(e) {
      if(this.animating) {
        // avoid dragging when animation is running
        return
      }
      this.dragging = true
      this.cursorStartX = getCursorX(e)
      this.cursorCurrentX = this.cursorStartX
    },
    drag(e) {
      if(!this.dragging) {
        // avoid updating if not dragging
        return
      }
      this.cursorCurrentX = getCursorX(e)
    },
    stopDrag(e) {
      let animationProps = this.createReleaseAnimation()

      this.dragging = false
      this.animating = true
      TweenLite.to(this, 0.2, {
        ...animationProps,
        onComplete: () => {this.animating = false}
      })
    },
    createReleaseAnimation() {
      if(this.swipingLeft) {
        if(this.nextImagePosition > HALF_WIDTH) {
          // next image should be animated back to be offscreen
          this.nextImageAnimatedX = this.nextImagePosition
          return {nextImageAnimatedX: DEVICE_WIDTH}
        } 
        
        // current image "copies" the nextImage position
        this.currentImageAnimatedX = this.nextImagePosition
        // the nextImage is sent offscreen
        this.nextImageAnimatedX = DEVICE_WIDTH
        
        // Change the image index to become the next image in the array
        // images src attribute will update accordingly
        this.currentImageIndex = this.nextImageIndex
        return {currentImageAnimatedX: 0}
      }
      
      // swipe right
      if(this.currentImagePosition < HALF_WIDTH) {
        // current image should be animated back to center position
        this.currentImageAnimatedX = this.currentImagePosition
        return {currentImageAnimatedX: 0}
      }
      
      // the nextImage "copies" the currentImage position
      this.nextImageAnimatedX = this.currentImagePosition
      // the currentImage gets centered to become the prevImage
      this.currentImageAnimatedX = 0
      
      // Change the image index to become the previous image in the array
      this.currentImageIndex = this.previousImageIndex
      return {nextImageAnimatedX: DEVICE_WIDTH}
    }
  },
  computed: {
    diffX () {
      return this.cursorStartX - this.cursorCurrentX
    },
    currentImage () {
      return images[this.currentImageIndex]
    },
    previousImageIndex () {
      return (this.currentImageIndex - 1 + images.length) % images.length
    },
    previousImage () {
      return images[this.previousImageIndex]
    },
    nextImageIndex () {
      return (this.currentImageIndex+1) % images.length
    },
    nextImage () {
      return images[this.nextImageIndex]
    },
    swipingLeft () {
      return this.diffX >= 0
    },
    currentImagePosition () {
      if(this.animating) {
        return this.currentImageAnimatedX
      }
      if(!this.dragging || this.swipingLeft) {
        return 0
      }
      const position = this.diffX * -DRAGGING_SPEED
      return clampPosition(position)
    },
    prevImageStyle () {
      const blur = calculateBlur(this.currentImagePosition)
      
      return {
        'filter': `blur(${blur}px)`
      }
    },
    currentImageStyle () {
      const blur = calculateBlur(this.nextImagePosition)
      
      return {
        'left': `${this.currentImagePosition}px`,
        'filter': `blur(${blur}px)`
      }
    },
    nextImagePosition () {
      if(this.animating) {
        return this.nextImageAnimatedX
      }
      const swipingRight = !this.swipingLeft
      if(!this.dragging || swipingRight) {
        return DEVICE_WIDTH
      }
      
      const position = DEVICE_WIDTH - (this.diffX * DRAGGING_SPEED)
      return clampPosition(position)
    },
    nextImageStyle () {
      return {
        'left': `${this.nextImagePosition}px`
      }
    }
  }
})

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/1.0.1/tailwind.min.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17-beta.0/vue.js
  2. https://cdnjs.cloudflare.com/ajax/libs/gsap/latest/TweenMax.min.js