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

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

ease-out
.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.

--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:
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!
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 */
};
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.

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.
The post Simple Swipe With Vanilla JavaScript appeared first on CSS-Tricks.