Swipe Gallery using Vue.js & Tailwind - WotW

Cover image

Welcome to the Widget of the Week series, where I take gifs or videos of awesome UI/UX components, and bring them to life with code.

Today we are going to create a swipe gallery that works with both touch or mouse controls. The inspiration comes from this submission created by RONGYU and looks like this:

wotw-pass

Who is this for?

This tutorial is aimed at front-end developers that want to level up their skills. It is recommended that you have some prior knowledge of HTML, CSS, JS. I'll be using Vue.js to make the widget, if you're not familiar to this framework these awesome posts can help you get up to speed:

Preparations

For today's widget, we will be using Vue.js, and for some animations, we'll use TweenMax. Also, I'll be using the newly released TailwindCSS v1.0.1. If you want to follow along you can fork this codepen template that already has the dependencies.

Creating the mobile viewport

First what I want to do is constrain the area of our widget container to match the size of a mobile device. For that I'll first write some CSS rules:

.mobile-container {
  width: 320px;
  height: 568px;
}

This will be the only CSS class we will need for the whole widget... that's right the rest of styling will be done using TailwindCSS.

Now to see it working we need to add some mark up to our widget, let's start by making our app container:

<div id="app" class="flex items-center justify-center bg-black w-screen h-screen"></div>

Those are a bunch of TailwindCSS classes, most of them are self-explanatory if you are used to writing CSS rules. From left to right they match to the following CSS rules:

display: flex;
align-items: center;
justify-content: center;
background-color: black;
width: 100vw;
height: 100vw;

As you can see we wrote less code and also we are able to make any changes without having to jump between the CSS file and the HTML one.

For the rest of the tutorial I won't be "translating" each TailwindCSS class, but I'll surely highlight the ones that matter the most. For the rest you can visit TailwindCSS documentation.

Now let's make use of the .mobile-container class that we created:

<!-- inside the app div -->
<div class="mobile-container relative overflow-hidden bg-white"></div>

We are making the container relative to be able to move the gallery images relatively to it, also overflow-hidden should help us to hide any content that gets outside of the container's box.

Now we should have something like this: styled

Images

To start making our gallery we need a couple of images to work with, you can use the following array of images I hosted for this widget:

// js
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'
]

Set up Vue.js

As usual in this series, we need to setup Vue.js to bind our js data to the HTML template and make our widget interactive:

new Vue({
  el: '#app',
  data: {
    currentImageIndex: 0
  }
})

Not much is happening right now, I'm only declaring a property in my data object that holds the index of the current item in our gallery.

How will it work?

Before going forward I need to show you a couple of diagrams that should help explain the real behavior of the gallery.

Our gallery should loop over all images in the array, but instead of creating all of the images we will only need to have 3 images at the same time: Previous, Current and Next

diagram-1

Whenever we swipe through the images, we can only see at most those three images so we will be doing a couple of "magic tricks" for them to look like if the gallery was infinite.

When swiping there are two outcomes, you drag the current image to the right and show the previous one or drag the next image on top of the current one:

diagram-2

After releasing the dragged image to either side we need to decide if the image stays in the center, or leaves the viewport:

diagram-3

For that we will take the image position if more than half of it is inside the viewport it stays, if not it leaves. Also to keep working with the same three images we should swap them accordingly.

Setup the images

We now know that three images are going to be rendered, we have the currentImageIndex already but we need the previous and the next one too. Also, it would be awesome if the image URL could be stored in a variable, or even better, computed properties:

  // after our data inside the Vue instance
  computed: {
    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]
    },
  }

Instead of using methods, computed properties help us to both simplify our component and improve its performance, computed properties are cached based on their reactive dependencies.

We have everything to start rendering the images, so let's get back to the HTML part:

<!-- inside .mobile-container -->

<!--  image below   -->
<img class="absolute h-full z-0" :src="previousImage" />

<!--  interactive image   -->
<img class="absolute h-full z-10" :src="currentImage" />

<!--  image above   -->
<img class="absolute h-full z-20" :src="nextImage" />

All three images need to be stacked above the previous ones, that's why we are using z-0, z-10, z-20 and absolute position. The h-full class ensures that the images fill vertically the space of the container.

We have a small problem, the nextImage as shown in the first diagram should be outside (on the right side) of the viewport. We will eventually also animate it so I will bind the style attribute to a computed property called nextImageStyle.

<!--  image above   -->
<img class="absolute h-full z-20" :style="nextImageStyle" :src="nextImage" />

Then we need to create that computed property, but it needs a constant referencing the device width:

// before the Vue instance
const DEVICE_WIDTH = 320

For this widget it is a hardcoded constant, but in a real-world scenario we should be able to get the device width and set that constant accordingly.

    // inside computed: {
    nextImagePosition () {
      return DEVICE_WIDTH
    },
    nextImageStyle () {
      return {
        'left': `${this.nextImagePosition}px`
      }
    }

We created a couple of computed properties for the styling, this seems like overkill but they will be useful when animating the images. After this, you should be seeing the first image, the waterfall, instead of some green leaves.

Getting user inputs

The user will interact with our gallery by touching or clicking the current image, then it will start moving the cursor or finger and after that, they should release the image.

Those are three events we need to listen:

  • start
  • move
  • end

The start event will always be triggered by the current image, but the other two events can happen either inside the image or outside the gallery container. The next step is to listen to those events and to be able to make it work both in mobile and desktop devices, we need to listen not only touch events but mouse events too:

<!-- add these 4 events to the app div -->
<div
  id="app"
  @mousemove="drag"
  @touchmove="drag"
  @mouseup="stopDrag"
  @touchend="stopDrag"
  class="flex items-center justify-center bg-black w-screen h-screen"
></div>
<!--  these 2 events go in the current image   -->
<img
  class="absolute h-full z-10"
  @mousedown.prevent="startDrag"
  @touchstart="startDrag"
  :src="currentImage"
/>

Notice the prevent modifier, this helps to prevent the regular drag and drop behavior that browsers add to images.

We have three different methods that need to be declared inside our Vue instance, but first, let's create a helper function to extract the position of either the mouse or the finger touching the screen:

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
}

We should be able to use this function to update accordingly the cursor movement, but we will also need to keep track of the initial click position and if the user is currently dragging the image:

// inside the vue instance data
    dragging: false,
    cursorStartX: 0,
    cursorCurrentX: 0

// after data
  methods: {
    startDrag(e) {
      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) {
      this.dragging = false
    }
  },

To see if all of this is working correctly, you can add this widget to see how the properties change:

<!-- inside the app div -->
<pre
  class="fixed bottom-0 left-0 p-3 text-white z-50 bg-gray-800 opacity-75 pointer-events-none"
>
dragging: {{ dragging }}
imagesIndexes: [{{previousImageIndex}}] [{{currentImageIndex}}] [{{nextImageIndex}}]
cursorStartX: {{ cursorStartX }}
cursorCurrentX: {{ cursorCurrentX }}</pre
>

Moving the images

Here comes the interesting part, for the next steps we will first declare some constants that we will be using:

// after DEVICE_WIDTH constant
const HALF_WIDTH = DEVICE_WIDTH / 2
const DRAGGING_SPEED = 1.2
const MAX_BLUR = 8

After we finish you can play with these values to see how things change.

Like I mentioned above there are two cases when dragging an image, either it is being dragged to the left or to the right. let's create a couple of computed props for that:

// inside computed
    diffX () {
      return this.cursorStartX - this.cursorCurrentX
    },
    swipingLeft () {
      return this.diffX >= 0
    },

Basically we are getting the difference between where the user started to drag and where the cursor is currently. If that difference is greater than 0 it means that the user is dragging the image to the left.

Before deep diving into moving the images, I'll create another helper function that should give us a hand when it comes to keeping the images inside the container

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

And now we can replace the nextImagePosition computed prop with this new one:

// inside computed, replacing the old one
    nextImagePosition () {
      const swipingRight = !this.swipingLeft
      if(!this.dragging || swipingRight) {
        return DEVICE_WIDTH
      }

      const position = DEVICE_WIDTH - (this.diffX * DRAGGING_SPEED)
      return clampPosition(position)
    },

Try it out!

The nextImage should come out when you press and drag to the left.

Whenever the user is not dragging or if the user is swiping right, we want the next image to be in the same spot outside the viewport. In the other case, the image should get closer to the center of the container depending on the dragging speed.

In the same way, we can do something similar for the currentImage when the user swipes right, first bind the style attribute:

<!--  interactive image   -->
<img
  class="absolute h-full z-10"
  @mousedown.prevent="startDrag"
  @touchstart="startDrag"
  :style="currentImageStyle"
  :src="currentImage"
/>

Then create the computed methods for that:

// inside computed
    currentImagePosition () {
      if(!this.dragging || this.swipingLeft) {
        return 0
      }
      const position = this.diffX * -DRAGGING_SPEED
      return clampPosition(position)
    },
    currentImageStyle () {
      return {
        'left': `${this.currentImagePosition}px`
      }
    },

The blur effect

In the reference, when the current image is being covered by the next one it gradually blurs to create an effect of being sent to the bottom. Let's create the last helper function:

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

This function should give us a value between 0 and the MAX_BLUR depending on the position of an image. When the image is closer to being outside the viewport there is less blur, when it is closer to being centered there's a bigger blur value.

Also our previous image will need a style attribute:

<!--  image below   -->
<img class="absolute h-full z-0" :style="prevImageStyle" :src="previousImage" />

The previousImage blur depends on the currentImage position, and the currentImage blur depends on the nextImage position:

// inside computed
    currentImageStyle () {
      const blur = calculateBlur(this.nextImagePosition)

      return {
        'left': `${this.currentImagePosition}px`,
        'filter': `blur(${blur}px)`
      }
    },
    prevImageStyle () {
      const blur = calculateBlur(this.currentImagePosition)

      return {
        'filter': `blur(${blur}px)`
      }
    },

Animate image after dropping it

So far, so good. Images are moving and blurring accordingly, but after we release them they just go back. We need a way to make them go where we want after we swiped.

Like we said before, depending if half of the image is showing or hiding we will animate it in or out.

Let's add some data props that we will need for this:

// inside data
    animating: false,
    currentImageAnimatedX: 0,
    nextImageAnimatedX: DEVICE_WIDTH

The animating property will let us know whenever we are moving an image and prevent any other action. currentImageAnimatedX and nextImageAnimatedX will hold the position when animating the corresponding images.

In order for those two properties to work correctly we need to update both images positioning computed props:

// inside computed
    nextImagePosition () {
      // add these 3 lines...
      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)
    },
    currentImagePosition () {
      // ... and these 3 too
      if(this.animating) {
        return this.currentImageAnimatedX
      }
      if(!this.dragging || this.swipingLeft) {
        return 0
      }
      const position = this.diffX * -DRAGGING_SPEED
      return clampPosition(position)
    },

Then we need to change the stopDrag method to trigger the animation:

// inside methods
    stopDrag(e) {
      let animationProps = this.createReleaseAnimation()

      this.dragging = false
      this.animating = true
      TweenLite.to(this, 0.2, {
        ...animationProps,
        onComplete: () => {this.animating = false}
      })
    },

We are using TweenLite to tween the Vue instance data, this will reactively update the images styles computed properties.

You may have noticed that we need to define the createReleaseAnimation, this is the method that will hold the logic to know where should images go after being released. This is some kind of decision tree, so I'll explain it with comments inline:

// inside methods
    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}
    }

What we are doing is defining each of the four cases:

  • Swiped left but the image should get back offscreen
  • Swiped left and the image should get to the center of the container
  • Swiped right and the image should get back to the center
  • Swiped right and the previous image should become the new current image

On each of those cases, we define an object that will be used by TweenLite to change the corresponding animatedX property to the target destination.

And now the final result!

I left a <pre> tag showing all of the properties as they update but feel free to remove it if you just want to see the gallery without it.

And that’s it for this Widget of the Week.

If you're hungry for more you can check other WotW:

Was the article useful?

You can support my Coffee Driven Posts by clicking the button below

buy me a coffee