Simple Swipe With Vanilla JavaScript

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:

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:

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:

### 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:

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:

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.

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:

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:

#### 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:

#### 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:

`transition: transform calc(var(--f)*.5s) cubic-bezier(1, 1.59, .61, .74);`

#### 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.

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:

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!

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 */ };`

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.

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:

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:

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:

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.