Simple Swipe with Vanilla JavaScript

Avatar of Ana Tudor
Ana Tudor on (Updated on )

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

I used to think implementing swipe gestures had to be very difficult, but I have recently found myself in a situation where I had to do it and discovered the reality is nowhere near as gloomy as I had imagined.

This article is going to take you, step by step, through the implementation with the least amount of code I could come up with. So, let’s jump right into it!

The HTML Structure

We start off with a .container that has a bunch of images inside:

<div class='container'>
  <img src='img1.jpg' alt='image description'/>
  ...
</div>

Basic Styles

We use display: flex to make sure images go alongside each other with no spaces in between. align-items: center middle aligns them vertically. We make both the images and the container take the width of the container’s parent (the body in our case).

.container {
  display: flex;
  align-items: center;
  width: 100%;
  
  img {
    min-width: 100%; /* needed so Firefox doesn't make img shrink to fit */
    width: 100%; /* can't take this out either as it breaks Chrome */
  }
}

The fact that both the .container and its child images have the same width makes these images spill out on the right side (as highlighted by the red outline) creating a horizontal scrollbar, but this is precisely what we want:

Screenshot showing this very basic layout with the container and the images having the same width as the body and the images spilling out of the container to the right, creating a horizontal scrollbar on the body.
The initial layout (see live demo).

Given that not all the images have the same dimensions and aspect ratio, we have a bit of white space above and below some of them. So, we’re going to trim that by giving the .container an explicit height that should pretty much work for the average aspect ratio of these images and setting overflow-y to hidden:

.container {
  /* same as before */
  overflow-y: hidden;
  height: 50vw;
  max-height: 100vh;
}

The result can be seen below, with all the images trimmed to the same height and no empty spaces anymore:

Screenshot showing the result after limiting the container's height and trimming everything that doesn't fit vertically with overflow-y. This means we now have a horizontal scrollbar on the container itself.
The result after images are trimmed by overflow-y on the .container (see live demo).

Alright, but now we have a horizontal scrollbar on the .container itself. Well, that’s actually a good thing for the no JavaScript case.

Otherwise, we create a CSS variable --n for the number of images and we use this to make .container wide enough to hold all its image children that still have the same width as its parent (the body in this case):

.container {
  --n: 1;
  width: 100%;
  width: calc(var(--n)*100%);
  
  img {
    min-width: 100%;
    width: 100%;
    width: calc(100%/var(--n));
  }
}

Note that we keep the previous width declarations as fallbacks. The calc() values won’t change a thing until we set --n from the JavaScript after getting our .container and the number of child images it holds:

const _C = document.querySelector('.container'), 
      N = _C.children.length;

_C.style.setProperty('--n', N)

Now our .container has expanded to fit all the images inside:

Layout with expanded container (live demo).

Switching Images

Next, we get rid of the horizontal scrollbar by setting overflow-x: hidden on our container’s parent (the body in our case) and we create another CSS variable that holds the index of the currently selected image (--i). We use this to properly position the .container with respect to the viewport via a translation (remember that % values inside translate() functions are relative to the dimensions of the element we have set this transform on):

body { overflow-x: hidden }

.container {
  /* same styles as before */
  transform: translate(calc(var(--i, 0)/var(--n)*-100%));
}

Changing the --i to a different integer value greater or equal to zero, but smaller than --n, brings another image into view, as illustrated by the interactive demo below (where the value of --i is controlled by a range input):

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, but we don’t want to use a slider to do this.

The basic idea is that we’re going to detect the direction of the motion between the "touchstart" (or "mousedown") event and the "touchend" (or "mouseup") and then update --i accordingly to move the container such that the next image (if there is one) in the desired direction moves into the viewport.

function lock(e) {};

function move(e) {};

_C.addEventListener('mousedown', lock, false);
_C.addEventListener('touchstart', lock, false);

_C.addEventListener('mouseup', move, false);
_C.addEventListener('touchend', move, false);

Note that this will only work for the mouse if we set pointer-events: none on the images.

.container {
  /* same styles as before */

  img {
    /* same styles as before */
    pointer-events: none;
  }
}

Also, Edge needs to have touch events enabled from about:flags as this option is off by default:

Screenshot showing the 'Enable touch events' option being set to 'Only when a touchscreen is detected' in about:flags in Edge.
Enabling touch events in Edge.

Before we populate the lock() and move() functions, we unify the touch and click cases:

function unify(e) { return e.changedTouches ? e.changedTouches[0] : e };

Locking on "touchstart" (or "mousedown") means getting and storing the x coordinate into an initial coordinate variable x0:

let x0 = null;

function lock(e) { x0 = unify(e).clientX };

In order to see how to move our .container (or if we even do that because we don’t want to move further when we have reached the end), we check if we have performed the lock() action, and if we have, we read the current x coordinate, compute the difference between it and x0 and resolve what to do out of its sign and the current index:

let i = 0;

function move(e) {
  if(x0 || x0 === 0) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);
  
    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
      _C.style.setProperty('--i', i -= s);
	
    x0 = null
  }
};

The result on dragging left/ right can be seen below:

Animated gif. Shows how we switch to the next image by dragging left/ right if there is a next image in the direction we want to go. Attempts to move to the right on the first image or left on the last one do nothing as we have no other image before or after, respectively.
Switching between images on swipe (live demo). Attempts to move to the right on the first image or left on the last one do nothing as we have no other image before or after, respectively.

The above is the expected result and the result we get in Chrome for a little bit of drag and Firefox. However, Edge navigates backward and forward when we drag left or right, which is something that Chrome also does on a bit more drag.

Animated gif. Shows how Edge navigates the pageview backward and forward when we swipe left or right.
Edge navigating the pageview backward or forward on left or right swipe.

In order to override this, we need to add a "touchmove" event listener:

_C.addEventListener('touchmove', e => {e.preventDefault()}, false)

Alright, we now have something functional in all browsers, but it doesn’t look like what we’re really after… yet!

Smooth Motion

The easiest way to move towards getting what we want is by adding a transition:

.container {
  /* same styles as before */
  transition: transform .5s ease-out;
}

And here it is, a very basic swipe effect in about 25 lines of JavaScript and about 25 lines of CSS:

Working swipe effect (live demo).

Sadly, there’s an Edge bug that makes any transition to a CSS variable-depending calc() translation fail. Ugh, I guess we have to forget about Edge for now.

Refining the Whole Thing

With all the cool swipe effects out there, what we have so far doesn’t quite cut it, so let’s see what improvements can be made.

Better Visual Cues While Dragging

First off, nothing happens while we drag, all the action follows the "touchend" (or "mouseup") event. So, while we drag, we have no indication of what’s going to happen next. Is there a next image to switch to in the desired direction? Or have we reached the end of the line and nothing will happen?

To take care of that, we tweak the translation amount a bit by adding a CSS variable --tx that’s originally 0px:

transform: translate(calc(var(--i, 0)/var(--n)*-100% + var(--tx, 0px)))

We use two more event listeners: one for "touchmove" and another for "mousemove". Note that we were already preventing backward and forward navigation in Chrome using the "touchmove" listener:

function drag(e) { e.preventDefault() };

_C.addEventListener('mousemove', drag, false);
_C.addEventListener('touchmove', drag, false);

Now let’s populate the drag() function! If we have performed the lock() action, we read the current x coordinate, compute the difference dx between this coordinate and the initial one x0 and set --tx to this value (which is a pixel value).

function drag(e) {
  e.preventDefault();

  if(x0 || x0 === 0)  
    _C.style.setProperty('--tx', `${Math.round(unify(e).clientX - x0)}px`)
};

We also need to make sure to reset --tx to 0px at the end and remove the transition for the duration of the drag. In order to make this easier, we move the transition declaration on a .smooth class:

.smooth { transition: transform .5s ease-out; }

In the lock() function, we remove this class from the .container (we’ll add it again at the end on "touchend" and "mouseup") and also set a locked boolean variable, so we don’t have to keep performing the x0 || x0 === 0 check. We then use the locked variable for the checks instead:

let locked = false;

function lock(e) {
  x0 = unify(e).clientX;
  _C.classList.toggle('smooth', !(locked = true))
};

function drag(e) {
  e.preventDefault();
  if(locked) { /* same as before */ }
};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0))
    _C.style.setProperty('--i', i -= s);
    _C.style.setProperty('--tx', '0px');
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

The result can be seen below. While we’re still dragging, we now have a visual indication of what’s going to happen next:

Swipe with visual cues while dragging (live demo).

Fix the transition-duration

At this point, we’re always using the same transition-duration no matter how much of an image’s width we still have to translate after the drag. We can fix that in a pretty straightforward manner by introducing a factor f, which we also set as a CSS variable to help us compute the actual animation duration:

.smooth { transition: transform calc(var(--f, 1)*.5s) ease-out; }

In the JavaScript, we get an image’s width (updated on "resize") and compute for what fraction of this we have dragged horizontally:

let w;

function size() { w = window.innerWidth };

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0)) {
      _C.style.setProperty('--i', i -= s);
      f = 1 - f
    }
		
    _C.style.setProperty('--tx', '0px');
    _C.style.setProperty('--f', f);
    _C.classList.toggle('smooth', !(locked = false));
    x0 = null
  }
};

size();

addEventListener('resize', size, false);

This now gives us a better result.

Go back if insufficient drag

Let’s say that we don’t want to move on to the next image if we only drag a little bit below a certain threshold. Because now, a 1px difference during the drag means we advance to the next image and that feels a bit unnatural.

To fix this, we set a threshold at let’s say 20% of an image’s width:

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      /* same as before */
    }
		
    /* same as before */
  }
};

The result can be seen below:

We only advance to the next image if we drag enough (live demo).

Maybe Add a Bounce?

This is something that I’m not sure was a good idea, but I was itching to try anyway: change the timing function so that we introduce a bounce. After a bit of dragging the handles on cubic-bezier.com, I came up with a result that seemed promising:

Animated gif. Shows the graphical representation of the cubic Bézier curve, with start point at (0, 0), end point at (1, 1) and control points at (1, 1.59) and (.61, .74), the progression on the [0, 1] interval being a function of time in the [0, 1] interval. Also illustrates how the transition function given by this cubic Bézier curve looks when applied on a translation compared to a plain ease-out.
What our chosen cubic Bézier timing function looks like compared to a plain ease-out.
transition: transform calc(var(--f)*.5s) cubic-bezier(1, 1.59, .61, .74);
Using a custom CSS timing function to introduce a bounce (live demo).

How About the JavaScript Way, Then?

We could achieve a better degree of control over more natural-feeling and more complex bounces by taking the JavaScript route for the transition. This would also give us Edge support.

We start by getting rid of the transition and the --tx and --f CSS variables. This reduces our transform to what it was initially:

transform: translate(calc(var(--i, 0)/var(--n)*-100%));

The above code also means --i won’t necessarily be an integer anymore. While it remains an integer while we have a single image fully into view, that’s not the case anymore while we drag or during the motion after triggering the "touchend" or "mouseup" events.

Annotated screenshots illustrating what images we see for --i: 0 (1st image), --i: 1 (2nd image), --i: .5 (half of 1st and half of 2nd) and --i: .75 (a quarter of 1st and three quarters of 2nd).
For example, while we have the first image fully in view, --i is 0. While we have the second one fully in view, --i is 1. When we’re midway between the first and the second, --i is .5. When we have a quarter of the first one and three quarters of the second one in view, --i is .75.

We then update the JavaScript to replace the code parts where we were updating these CSS variables. First, we take care of the lock() function, where we ditch toggling the .smooth class and of the drag() function, where we replace updating the --tx variable we’ve ditched with updating --i, which, as mentioned before, doesn’t need to be an integer anymore:

function lock(e) {
  x0 = unify(e).clientX;
  locked = true
};

function drag(e) {
  e.preventDefault();
	
  if(locked) {
    let dx = unify(e).clientX - x0, 
      f = +(dx/w).toFixed(2);
		
    _C.style.setProperty('--i', i - f)
  }
};

Before we also update the move() function, we introduce two new variables, ini and fin. These represent the initial value we set --i to at the beginning of the animation and the final value we set the same variable to at the end of the animation. We also create an animation function ani():

let ini, fin;

function ani() {};

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
        s = Math.sign(dx), 
        f = +(s*dx/w).toFixed(2);
		
    ini = i - s*f;

    if((i > 0 || s < 0) && (i < N - 1 || s > 0) && f > .2) {
      i -= s;
      f = 1 - f
    }

    fin = i;
    ani();
    x0 = null;
    locked = false;
  }
};

This is not too different from the code we had before. What has changed is that we’re not setting any CSS variables in this function anymore but instead set the ini and the fin JavaScript variables and call the animation ani() function.

ini is the initial value we set --i to at the beginning of the animation that the "touchend"/ "mouseup" event triggers. This is given by the current position we have when one of these two events fires.

fin is the final value we set --i to at the end of the same animation. This is always an integer value because we always end with one image fully into sight, so fin and --i are the index of that image. This is the next image in the desired direction if we dragged enough (f > .2) and if there is a next image in the desired direction ((i > 0 || s < 0) && (i < N - 1 || s > 0)). In this case, we also update the JavaScript variable storing the current image index (i) and the relative distance to it (f). Otherwise, it’s the same image, so i and f don’t need to get updated.

Now, let’s move on to the ani() function. We start with a simplified linear version that leaves out a change of direction.

const NF = 30;

let rID = null;

function stopAni() {
  cancelAnimationFrame(rID);
  rID = null
};

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/NF);
	
  if(cf === NF) {
    stopAni();
    return
  }
	
  rID = requestAnimationFrame(ani.bind(this, ++cf))
};

The main idea here is that the transition between the initial value ini and the final one fin happens over a total number of frames NF. Every time we call the ani() function, we compute the progress as the ratio between the current frame index cf and the total number of frames NF. This is always a number between 0 and 1 (or you can take it as a percentage, going from 0% to 100%). We then use this progress value to get the current value of --i and set it in the style attribute of our container _C. If we got to the final state (the current frame index cf equals the total number of frames NF, we exit the animation loop). Otherwise, we just increment the current frame index cf and call ani() again.

At this point, we have a working demo with a linear JavaScript transition:

Version with linear JavaScript transition (live demo).

However, this has the problem we initially had in the CSS case: no matter the distance, we have to have to smoothly translate our element over on release ("touchend" / "mouseup") and the duration is always the same because we always animate over the same number of frames NF.

Let’s fix that!

In order to do so, we introduce another variable anf where we store the actual number of frames we use and whose value we compute in the move() function, before calling the animation function ani():

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
		
    /* same as before */

    anf = Math.round(f*NF);
    ani();

    /* same as before */
  }
};

We also need to replace NF with anf in the animation function ani():

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*cf/anf);
	
  if(cf === anf) { /* same as before */ }
	
  /* same as before */
};

With this, we have fixed the timing issue!

Version with linear JavaScript transition at constant speed (live demo).

Alright, but a linear timing function isn’t too exciting.

We could try the JavaScript equivalents of CSS timing functions such as ease-in, ease-out or ease-in-out and see how they compare. I’ve already explained in a lot of detail how to get these in the previously linked article, so I’m not going to go through that again and just drop the object with all of them into the code:

const TFN = {
  'linear': function(k) { return k }, 
  'ease-in': function(k, e = 1.675) {
    return Math.pow(k, e)
  }, 
  'ease-out': function(k, e = 1.675) {
    return 1 - Math.pow(1 - k, e)
  }, 
  'ease-in-out': function(k) {
    return .5*(Math.sin((k - .5)*Math.PI) + 1)
  }
};

The k value is the progress, which is the ratio between the current frame index cf and the actual number of frames the transition happens over anf. This means we modify the ani() function a bit if we want to use the ease-out option for example:

function ani(cf = 0) {
  _C.style.setProperty('--i', ini + (fin - ini)*TFN['ease-out'](cf/anf));
	
  /* same as before */
};
Version with ease-out JavaScript transition (live demo).

We could also make things more interesting by using the kind of bouncing timing function that CSS cannot give us. For example, something like the one illustrated by the demo below (click to trigger a transition):

See the Pen by thebabydino (@thebabydino) on CodePen.

The graphic for this would be somewhat similar to that of the easeOutBounce timing function from easings.net.

Animated gif. Shows the graph of the bouncing timing function. This function has a slow, then accelerated increase from the initial value to its final value. Once it reaches the final value, it quickly bounces back by about a quarter of the distance between the final and initial value, then going back to the final value, again bouncing back a bit. In total, it bounces three times. On the right side, we have an animation of how the function value (the ordinate on the graph) changes in time (as we progress along the abscissa).
Graphical representation of the timing function.

The process for getting this kind of timing function is similar to that for getting the JavaScript version of the CSS ease-in-out (again, described in the previously linked article on emulating CSS timing functions with JavaScript).

We start with the cosine function on the [0, 90°] interval (or [0, π/2] in radians) for no bounce, [0, 270°] ([0, 3·π/2]) for 1 bounce, [0, 450°] ([0, 5·π/2]) for 2 bounces and so on… in general it’s the [0, (n + ½)·180°] interval ([0, (n + ½)·π]) for n bounces.

See the Pen by thebabydino (@thebabydino) on CodePen.

The input of this cos(k) function is in the [0, 450°] interval, while its output is in the [-1, 1] interval. But what we want is a function whose domain is the [0, 1] interval and whose codomain is also the [0, 1] interval.

We can restrict the codomain to the [0, 1] interval by only taking the absolute value |cos(k)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

While we got the interval we wanted for the codomain, we want the value of this function at 0 to be 0 and its value at the other end of the interval to be 1. Currently, it’s the other way around, but we can fix this if we change our function to 1 - |cos(k)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we can move on to restricting the domain from the [0, (n + ½)·180°] interval to the [0, 1] interval. In order to do this, we change our function to be 1 - |cos(k·(n + ½)·180°)|:

See the Pen by thebabydino (@thebabydino) on CodePen.

This gives us both the desired domain and codomain, but we still have some problems.

First of all, all our bounces have the same height, but we want their height to decrease as k increases from 0 to 1. Our fix in this case is to multiply the cosine with 1 - k (or with a power of 1 - k for a non-linear decrease in amplitude). The interactive demo below shows how this amplitude changes for various exponents a and how this influences the function we have so far:

See the Pen by thebabydino (@thebabydino) on CodePen.

Secondly, all the bounces take the same amount of time, even though their amplitudes keep decreasing. The first idea here is to use a power of k inside the cosine function instead of just k. This manages to make things weird as the cosine doesn’t hit 0 at equal intervals anymore, meaning we don’t always get that f(1) = 1 anymore which is what we’d always need from a timing function we’re actually going to use. However, for something like a = 2.75, n = 3 and b = 1.5, we get a result that looks satisfying, so we’ll leave it at that, even though it could be tweaked for better control:

Screenshot of the previously linked demo showing the graphical result of the a = 2.75, n = 3 and b = 1.5 setup: a slow, then fast increase from 0 (for f(0)) to 1, bouncing back down less than half the way after reaching 1, going back up and then having another even smaller bounce before finishing at 1, where we always want to finish for f(1).
The timing function we want to try.

This is the function we try out in the JavaScript if we want some bouncing to happen.

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, n = 3, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

Hmm, seems a bit too extreme in practice:

Version with a bouncing JavaScript transition (live demo).

Maybe we could make n depend on the amount of translation we still need to perform from the moment of the release. We make it into a variable which we then set in the move() function before calling the animation function ani():

const TFN = {
  /* the other function we had before */
  'bounce-out': function(k, a = 2.75, b = 1.5) {
    return 1 - Math.pow(1 - k, a)*Math.abs(Math.cos(Math.pow(k, b)*(n + .5)*Math.PI))
  }
};

var n;

function move(e) {
  if(locked) {
    let dx = unify(e).clientX - x0, 
      s = Math.sign(dx), 
      f = +(s*dx/w).toFixed(2);
    
    /* same as before */
		
    n = 2 + Math.round(f)
    ani();
    /* same as before */
  }
};

This gives us our final result:

Version with the final bouncing JavaScript transition (live demo).

There’s definitely still room for improvement, but I don’t have a feel for what makes a good animation, so I’ll just leave it at that. As it is, this is now functional cross-browser (without have any of the Edge issues that the version using a CSS transition has) and pretty flexible.