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:

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:
transition-duration
Fix the 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:
![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.](https://i0.wp.com/css-tricks.com/wp-content/uploads/2018/03/bounce_css.gif?ssl=1)
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.
Worth using Pointer Events rather than Touch Events?
Probably. But I have a long way to go before I understand Pointer Events.
They’re, in essence, mouse events with extra attributes (since they extend mouse events). Would make it possible to have the slider working the same (or differently, as you can differentiate) for touch, stylus, and even mouse itself, all with same code.
Here’s a good article on how to use Pointer Events. https://developers.google.com/web/fundamentals/design-and-ux/input/touch/#add_event_listeners
Might want to add your
mouseup
listener towindow
as well. If in the process of clicking and dragging, your mouse leaves the browser window, you can release the mouse button and the container will miss the event. Listeners attached towindow
get called even when thatmouseup
happens outside the window.Oopsie… I always thought
addEventListener
was the same aswindow.addEventListener
…This is pretty damn cool, but how in the hell do you remember what each of the one-letter variables does? I lost track whilst reading through this :(
Naming is always hard. One convention may be easier for some than it is for others but thankfully the outcome doesn’t depend on one convention over another. :)
I don’t because I don’t need to remember them, I just need to understand them from the context. That’s why I use single letters. If I were to use longer variable names, I would have trouble understanding them, mapping them to the concept they define. I don’t know how to explain this, but I don’t really think in words, sentences.
If I see a variable
a
set to a degree or radian value, I instantly visualise a geometric drawing of an angle. If I see a variableangleForBlaBla
, I just panic because there are too many letters and I don’t understand what that thing is saying, what exactly is that’s relevant in there.I don’t know, it’s just the way my brain works. It’s why I have trouble reading and writing. It’s why I use so many visual demos in my articles. I have absolutely no idea how I could explain some things with words. Or why I wouldn’t understand explanations if they were given in words instead of in a visual manner.
Oh damn, that’s a crazy reason. I guess this is something that we couldn’t ever see eye-to-eye on based entirely on the way out brains work. I’m not saying it’s wrong, not at all, I struggle understanding it for the exact reason you find it easy. I can’t infer context easily.
cool, but why do this? libraries exist that already do this for you. this is so much coding time for such a simple action.
Because of the very valid point made here (#1): https://www.leaseweb.com/labs/2013/07/10-very-good-reasons-to-stop-using-javascript/
Did you look at the amount of code in the actual demo? Serious question.
I may have written a very long article about it, but actually coding the swipe was very little coding time, way less than I would have spent implementing it with a library. It took me less than half an hour to do the JS part for my initial swipe and half of that time was spent on refining and tweaking.
Using a library would have meant using that half an hour just on finding and deciding on a library. It would have taken me a day at the very least to understand how to use the library. I don’t know about others, but that’s the biggest turnoff for me when it comes to libraries – I just have an insanely hard time understanding how to use them if I even manage to understand how to use them at all. Even when they have excellent docs. So even if using the library would have meant me writing just two lines of JS, it would have still been way more time spent on it all.
Might be a stupid question but how do you set the variable
--i
at the beginning?This line of code:
transform: translate(calc(var(--i)/var(--n)*-100%));
It’s actually a good question.
I don’t. Not from the CSS and not even from the JS initially. I only set it from the JS when switching to another image. If it’s not set at all at the beginning the effect is the same as it being set to
0
. Since it doesn’t make a difference, I take the lazy approach and I just don’t set it.Nice approach. The animation functions are always a bit intimidating to read, maybe because I don’t have a math background ;-) Having more human readable variable names would help.
I built a similar carousel a while ago, which doesn’t use CSS variables. Instead, each item in the carousel animates itself (using
TranslateX(-100%)
. The advantage is that you don’t have to calculate the width of the container, the width of each image and the number of items in the carousel. If you’re curious (of course any feedback is welcome):https://github.com/reinoute/progressive-carousel
Great article, love how you progress through the implementation. Must have taken quite some time to write!
I also like how you calmly add these interactive graphs and then they turn out to have more code than your slider
Anyway the one thing I don’t understand is why you use calc() when you can just calculate all values in js? Then you wouldn’t have the transition issue with Edge?
True, I wouldn’t have the issue that way. I guess I just hate reading and computing values in the JS. Numerical computations in general.
But yeah, it’s definitely a valid option, especially in practice.
As for the graphics/ helper demos… they are more complex in the back than the swipe demo and they did take longer. And sometimes I’m at the edge of my abilities with the helper interactive stuff.
Thanks for sharing such an amazing article, really informative
It was an amazing tutorial! thank you so much!!
But please try to remember we are not in C any more. We can use long names for variables in 2018 :)
it would be easy for n00bs to read and understand ;)
That tutorial is so great!!! Step by step, so clear. I can follow them easily! thanks
Great article! CSS (tricks) first, JS enhancements later, like my http://radogado.github.io/native-slider/
Regards.
Hi! Great examples. I use another one today, but it doesn’t work as I want it to (auto height). Mine will instead use te height of the tallest card. Could I use yours, and somehow also strip the JS-file to only include the settings for the auto-height one somehow?
Hi Adrian, thanks. Auto height is just an option and I’m afraid all of the JS is required. This tool is a subset of https://github.com/radogado/natuive
Good luck!