How to create a Slider in pure JavaScript

Fionna Chan
12 min readMay 6, 2018
The demo slider we will create after finishing this article.

There are hundreds of slider plugins on github. They have lots of features to help developers get their job done more easily. But there is a single drawback — what if you only want a simple slider with one slide, carousel dots, arrows, and swiping? In most cases, we do not need the other rich features, but only what I mentioned. In this case, I always use the slider plugin written by myself using CSS3 and pure JavaScript so that I can exclude the functions I don’t use that are present in those feature-rich slider plugins.

So, how do we write a slider? For developers who always use open source libraries, the thought of writing it may seem difficult. For absolute beginners in the front-end development world, yes it is quite difficult. But for front-end developers with 1.5+ year experience, it should not be that hard.

Let’s start with the DOM

<div class="slider">
<div class="slide"><span>TEXT</span></div>
<div class="slide"><span>TEXT</span></div>
<div class="slide"><span>TEXT</span></div>
<div class="slide"><span>TEXT</span></div>
<div class="slide"><span>TEXT</span></div>
</div>
<a class="arrow-left" href="javascript:void(0);"></a>
<a class="arrow-right" href="javascript:void(0);"></a>
<div class="dots-wrapper"></div>

The DOM part is very straightforward. There aren’t any dots because they will be generated by the JavaScript so that the event listeners are attached properly.

CSS (SCSS)

I am going to use CodePen to demonstrate my slider. For CSS, I always use SCSS. The finished slider will have a background-fixed effect when the slide is changing. Since the CSS depends very much on what effect you would like to achieve, please only take reference of mine, and tweak it as you wish, or simply write your own.

body {
min-height: 100vh;
margin: 0;
}
.dots-wrapper {
position: absolute;
width: 100px;
left: 50%;
margin-left: -50px;
bottom: 20px;
text-align: center;
li {
display: inline-block;
width: 12px;
height: 12px;
background-color: white;
border-radius: 50%;
margin: 0 4px;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.3s;
&.active,
&:hover {
opacity: 1;
}
}
}
.slider {
position: relative;
width: 100%;
min-height: 100vh;
text-align: center;
margin: 0 auto;
cursor: pointer;
overflow: hidden;
.slider-inner {
position: absolute;
&:after {
content: "";
display: table;
clear: both;
}
.slide {
float: left;
box-sizing: border-box;
background-size: 100% auto;
background-attachment: fixed;
background-position: center center;
&.slide1 {
background-image: url('http://blogs-images.forbes.com/rosatrieu/files/2014/08/Valencia_market_-_lemons-1940x1454.jpg');
}
&.slide2 {
background-image: url('http://i.huffpost.com/gen/2930708/images/o-GMO-ORANGES-facebook.jpg');
}
&.slide3 {
background-image: url('http://blogs.kcrw.com/goodfood/wp-content/uploads/2013/05/watermelon.jpeg');
}
&.slide4 {
background-image: url('http://lghttp.32478.nexcesscdn.net/80E972/organiclifestylemagazine/wp-content/uploads/2013/09/Blueberries-.jpg');
}
&.slide5 {
background-image: url('https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Limes.jpg/1200px-Limes.jpg');
}
img {
display: block;
}
span {
cursor: default;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: block;
width: 100vw;
height: 100vh;
top: 12vw;
font-size: 10vw;
line-height: 100vh;
letter-spacing: 1vw;
font-family: Impact, Charcoal, sans-serif;
text-shadow: 5px 5px 0px #ffffff;
}
}
}
}
a[class|="arrow"] {
position: absolute;
display: block;
top: 50%;
margin-top: -20px;
width: 40px;
height: 40px;
}
.arrow-left {
left: 20px;
border-bottom: 6px solid #ffffff;
border-left: 6px solid #ffffff;
transform: rotate(45deg);
transition: left 0.5s;
&:hover {
left: 15px;
}
}
.arrow-right {
right: 20px;
border-bottom: 6px solid #ffffff;
border-right: 6px solid #ffffff;
transform: rotate(-45deg);
transition: right 0.5s;
&:hover {
right: 15px;
}
}

From the above block of SCSS, there’s only one thing to note:

-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;

This should be included for the slide to prevent highlight of the images or texts when the user drags the slide.

JavaScript

OK. Time to talk about the core — our JavaScript!

Depending on which IE version you support, as I build websites for Asia (Hong Kong, China, Taiwan, Korea, Japan, Vietnam, Thailand, and Indonesia), I usually support IE9+, so I will have to add some utility functions like hasClass, addClass and removeClass. If you don’t support IE, or only support IE11, you can just use classList in JavaScript. And for querySelector, I like to shorten it and use $, so here are the utility functions:

function $(elem) {
return document.querySelector(elem);
}
function hasClass(el, className) {
return el.classList ? el.classList.contains(className) : new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className);
}
function addClass(el, className) {
if (el.classList) {
el.classList.add(className);
} else {
el.className += ' ' + className
}
}
function removeClass(el, className) {
if (el.classList) {
el.classList.remove(className);
} else {
el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' ');
}
}

These are just some very standard utility functions to include if you are not using jQuery or frameworks like Angular / React.

I find this website super useful, and I highly recommend you to take a look: http://youmightnotneedjquery.com/

I wrote this plugin more than a year ago with ES5, so I am going to explain with ES5 in this article. You can code it in ES6 if you feel more comfortable with the newer syntax.

Since I do not want the functions for the slider to be accessible in the console, I started the plugin code with this:

var slider_plugin = (function() {
var fifi_slider = function(settings) {
}
return fifi_slider;
})();

I assigned an IIFE to the global variable slider_plugin so that I can keep the functions for the slider private.
If you don’t know what an IIFE is, you can read it on Wikipedia or google it for more information.

In the core function fifi_slider, the “settings” for the slider is passed to allow customization during initialization. Since this slider plugin is intended to be simple, the settings will be minimal.

var fifi_slider = function(settings) {
var _ = this;
_.def = {
target: $('.slider'),
dotsWrapper: $('.dots-wrapper'),
arrowLeft: $('.arrow-left'),
arrowRight: $('.arrow-right'),
transition: {
speed: 300,
easing: ''
},
swipe: true,
autoHeight: true,
afterChangeSlide: function afterChangeSlide() {}
};
}

I like to use var _ = this because it’s a lot shorter than this, and to avoid the confusion of this within different private functions, but in this plugin code, it would be totally fine to use this directly, with the help of .bind(this) to other scopes for using the slider this. I myself do not have a strong opinion against using var _ = this obviously, but it is considered by some to be a bad practice because var _ = this is copying this when you can actually just use this directly. (If you get confused by what I am saying here, google & stackoverflow.) To finish the slider plugin code, you can ignore the discussion on this and just follow my code.

Getting back to the configuration, the slides in this slider will loop. There is not an option for a non-looping slider, but it is pretty easy to modify our finished plugin code to make the plugin support the no-loop function. If you find any difficulties, feel free to comment below so that I can explain more.

We are going to use CSS3 transitions for the slide. So the “easing” is the easing you normally pass in CSS transition-timing-function, ease / linear / ease-in / ease-out / ease-in-out / (I don’t recommend step-start / step-end / steps) / cubic-bezier. Note that CSS3 transitions do not work on IE9, it may be jumpy from slide to slide after swiping.

The rest of the configuration should be easy to understand. I will not go through them one by one.

Now we have the default settings, and the settings passed in at initialization, how do we combine them? In jQuery, there is a function called $.extend(target, object1, [objectN]) that merges the content of two or more objects together into the target. Since we are not using jQuery, let’s create a simplified version of this function as a utility function:

function $extendObj(_def, addons) {
if (typeof addons !== "undefined") {
for (var prop in _def) {
if (addons[prop] != undefined) {
_def[prop] = addons[prop];
}
}
}
}

In this function I only did minimal error handling — checked if addons passed is a valid variable and that _def contains the keys of addons. I didn’t check if typeof addons === “object” because an array would return true in this case, so at this step we are not checking if addons is an object for sure. addons[prop] != undefined would return false if addons is not an object. It is enough for our purpose of merging the values of the custom settings to the default settings. Note that we only merge values if the defaults have that key.

In the core function, we put this line $extendObj(_.def, settings);

And then we initialize the slider by calling _.init();

There are quite a lot of private functions in the init function, so I will explain later. For now, just keep in mind that we need to declare these variables in init():

_.curSlide = 0;
_.curLeft = 0;
_.totalSlides = _.def.target.querySelectorAll('.slide').length;

Let’s take a look at how to build the carousel navigation dots first.

buildDots()

buildDots() is only called once in init() .

fifi_slider.prototype.buildDots = function () {
var _ = this;
for (var i = 0; i < _.totalSlides; i++) {
var dot = document.createElement('li');
dot.setAttribute('data-slide', i + 1);
_.def.dotsWrapper.appendChild(dot);
}
_.def.dotsWrapper.addEventListener('click', function (e) {
if (e.target && e.target.nodeName == "LI") {
_.curSlide = e.target.getAttribute('data-slide');
_.gotoSlide();
}
}, false);
}

In this function, the for loop builds the dots <li data-slide="(slide number)"></li>, append them to <div class="dots-wrapper"></div> for the slides. Right after that, we add a click event listener to the dots wrapper. When a li is clicked, we set the current slide to the slide number stored in the data attribute of this li, and then transit to that slide through calling gotoSlide().

init()

There are a lot of functions only called once at initialization, and therefore in this init() there are a lot of private functions. I will explain them one by one.

init() — window on resize

All websites should be responsive nowadays, so we have to make the sliders responsive too. On resize of the browser window, the sliders should fit in the new window size. The code below is pretty self-explanatory. One special thing here is that I wrapped the function with another function called on_resize. It is a simple debounce function to make the function fires less often.

window.addEventListener("resize", on_resize(function() {
_.updateSliderDimension();
}), false);
function on_resize(c, t) {
onresize = function() {
clearTimeout(t);
t = setTimeout(c, 100);
}
return onresize;
}

Init() — lazy load images

A decent front-end developer should always try to optimize the webpage for better user experience. That’s why I added this lazy load function for images in the sliders. To enjoy the benefits of lazy loading, put the image source like so: <img data-src="(image path here)" alt="" />

function loadedImg(el) {
var loaded = false;
function loadHandler() {
if (loaded) {
return;
}
loaded = true;
_.loadedCnt++;
if (_.loadedCnt >= _.totalSlides + 2) {
_.updateSliderDimension();
}
}
var img = el.querySelector('img');
if (img) {
img.onload = loadHandler;
img.src = img.getAttribute('data-src');
img.style.display = 'block';
if (img.complete) {
loadHandler();
}
} else {
_.updateSliderDimension();
}
}

The code above should be pretty easy to understand. The only confusing part should be _.totalSlides + 2 . It is +2 because in order to achieve the loop effect of the slider, we need to clone the first slide and put the clone at the end of the slider, and clone the last slide and put the clone at the beginning of the slider.

init() — multiple events for event listeners

If you are familiar with both pure JavaScript and jQuery, you would know that in pure JavaScript you can only attach one event to the event listener every time. I do not want to have two lines for the same function every time so I used these two util functions. Otherwise you can copy and paste el.addEventListener(e, fn, false) to achieve the same as what I did in addListenerMulti(el, s, fn). It is used like this: addListenerMulti(_.sliderInner, 'mousedown touchstart', startSwipe)

function addListenerMulti(el, s, fn) {
s.split(' ').forEach(function(e) {
return el.addEventListener(e, fn, false);
});
}
function removeListenerMulti(el, s, fn) {
s.split(' ').forEach(function(e) {
return el.removeEventListener(e, fn, false);
});
}

init() — core

To make the slider works, we have to wrap the slides in an extra wrapper. I chose to do it in the plugin code, but you can also put it in the DOM and remove this from the plugin code.

var nowHTML = _.def.target.innerHTML;
_.def.target.innerHTML = '<div class="slider-inner">' + nowHTML + '</div>';

Here I initialized respectively the number of all slides (0), the index of the current slide (0), the CSS left property value of the whole slider (0)px, the number of total slides, saving the slider inner wrapper I just added as a DOM target, and the number of lazy-load images loaded.

_.allSlides = 0;
_.curSlide = 0;
_.curLeft = 0;
_.totalSlides = _.def.target.querySelectorAll('.slide').length;
_.sliderInner = _.def.target.querySelector('.slider-inner');
_.loadedCnt = 0;

To achieve the loop effect of the slider, we need to clone the first and the last slides as I mentioned earlier.

var cloneFirst = _.def.target.querySelectorAll('.slide')[0].cloneNode(true);
_.sliderInner.appendChild(cloneFirst);
var cloneLast = _.def.target.querySelectorAll('.slide')[_.totalSlides - 1].cloneNode(true);
_.sliderInner.insertBefore(cloneLast, _.sliderInner.firstChild);

After cloning, the index of the current slide would be +1. We need a variable to store the DOM elements for all the slides including the clones for later use.

_.curSlide++;
_.allSlides = _.def.target.querySelectorAll('.slide');

The slider inner wrapper added by code is set to be position: absolute; in the CSS in order to make the slider work. Its width has to be the total width of all slides. The script below takes care of this part, and then load the lazy-load images.

_.sliderInner.style.width = (_.totalSlides + 2) * 100 + "%";
for (var _i = 0; _i < _.totalSlides + 2; _i++) {
_.allSlides[_i].style.width = 100 / (_.totalSlides + 2) + "%";
loadedImg(_.allSlides[_i]);
}

The rest of the core script in init() is self-explanatory:

_.buildDots();
_.setDot();
_.initArrows();
if (_.def.swipe) {
addListenerMulti(_.sliderInner, 'mousedown touchstart', startSwipe);
}
_.isAnimating = false;

getCurLeft()

As the function name says, we get the current CSS left value of the slider inner to know which slide is the active slide/the slide showing.

fifi_slider.prototype.getCurLeft = function () {
var _ = this;
_.curLeft = parseInt(_.sliderInner.style.left.split('px')[0]);
}

init() — startSwipe()

In this function, we call getCurLeft() for animating the slide if the swipe happens after the initial touchstart event. We only allow the touchmove handler event to occur if the slider is not already in animation.

function startSwipe(e) {
var touch = e;
_.getCurLeft();
if (!_.isAnimating) {
if (e.type == 'touchstart') {
touch = e.targetTouches[0] || e.changedTouches[0];
}
_.startX = touch.pageX;
_.startY = touch.pageY;
addListenerMulti(_.sliderInner, 'mousemove touchmove', swipeMove);
addListenerMulti($('body'), 'mouseup touchend', swipeEnd);
}
}

init — swipeMove()

In startSwipe() we saved the touch.pageX and touch.pageY as startX and startY. Here in swipeMove() we save them into moveX and moveY instead because we have to use the difference to decide if we animate the slider, and to actually perform the swipe/drag animation.

function swipeMove(e) {
var touch = e;
if (e.type == 'touchmove') {
touch = e.targetTouches[0] || e.changedTouches[0];
}
_.moveX = touch.pageX;
_.moveY = touch.pageY;
// for scrolling up and down
if (Math.abs(_.moveX - _.startX) < 40) return;
_.isAnimating = true;
addClass(_.def.target, 'isAnimating');
e.preventDefault();
if (_.curLeft + _.moveX - _.startX > 0 && _.curLeft == 0) {
_.curLeft = -_.totalSlides * _.slideW;
} else if (_.curLeft + _.moveX - _.startX < -(_.totalSlides + 1) * _.slideW) {
_.curLeft = -_.slideW;
}
_.sliderInner.style.left = _.curLeft + _.moveX - _.startX + "px";
}

init — swipeEnd()

When the swipe ends, the slide is probably not fully changed because users do not usually drag the slide to the exact position where the slide changes fully or where it fits the slider perfectly. Here we check how wide was the swipe. If there was no swiping at all, we stop proceeding. If the swipe was less than 40px, we stay on the current slide, otherwise we change to the next slide. We also check the direction of the swipe by comparing startX and moveX. We also check if the new slide being swiped to is a cloned slide or not. If it is a cloned slide, we will set the index of the current slide to that of the original slide instead of the clone’s.

gotoSlide() takes care of the transition from the partially-dragged slide to the next slide. We delete startX, startY, moveX, and moveY because we do not need to store their values for the next swipe. The event listeners attached have to be removed because we attach them every time at startSwipe(). If we do not remove these at swipeEnd(), they would be fired multiple times and result in a weird behavior.

function swipeEnd(e) {
var touch = e;
_.getCurLeft();
if (Math.abs(_.moveX - _.startX) === 0) return;_.stayAtCur = Math.abs(_.moveX - _.startX) < 40 || typeof _.moveX === "undefined" ? true : false;
_.dir = _.startX < _.moveX ? 'left' : 'right';
if (_.stayAtCur) {} else {
_.dir == 'left' ? _.curSlide-- : _.curSlide++;
if (_.curSlide < 0) {
_.curSlide = _.totalSlides;
} else if (_.curSlide == _.totalSlides + 2) {
_.curSlide = 1;
}
}
_.gotoSlide();delete _.startX;
delete _.startY;
delete _.moveX;
delete _.moveY;
_.isAnimating = false;
removeClass(_.def.target, 'isAnimating');
removeListenerMulti(_.sliderInner, 'mousemove touchmove', swipeMove);
removeListenerMulti($('body'), 'mouseup touchend', swipeEnd);
}

gotoSlide()

This function is to change the slide, so it is called when the dot is clicked, when the arrow is clicked, and when the swipe ends. Here we perform the transition animation with the help of CSS3. The code here should be easy to understand without explanation.

fifi_slider.prototype.gotoSlide = function () {
var _ = this;
_.sliderInner.style.transition = 'left ' + _.def.transition.speed / 1000 + 's ' + _.def.transition.easing;_.sliderInner.style.left = -_.curSlide * _.slideW + 'px';addClass(_.def.target, 'isAnimating');setTimeout(function () {
_.sliderInner.style.transition = '';
removeClass(_.def.target, 'isAnimating');
}, _.def.transition.speed);

--

--

Fionna Chan

Frontend Unicorn. I 💖 CSS & JavaScript. My brain occasionally runs out of memory so I need to pen down my thoughts. ✨💻🦄🌈.y.at