Build lightweight and performant Carousel using pure JavaScript
Lately, I decided to have a performant and lightweight slider in my components arsenal so I don't need to rely on GitHub projects so much. There are a lot of great libraries around the internet however, they usually come with the additional features that most of the time I don't need. Normally I would go into the source and strip what's useless but sometimes when there is time I will study how given lib works. In the past I have created some sliders on my own according to the"every front-end developer should know how to create a simple component such as slider" idea.
This article describes my journey to the Holy Grail of JavaScript Sliders. Intentionally I have skipped some details to keep this article short. If you are looking for a well-tested and popular solution that you can use on medium< size project then I highly recommend EmblaCarousel which was my inspiration from the very beginning. It also has features such as free dragging, which I did not want to describe in this tutorial.
Aims of this project are:
- create a solution for the newest browsers only
- avoid website reflows if possible
- the animation should be as smooth as possible - even for slow devices
- component should be simple
- slider should be lightweight and rely on CSS
- avoid polyfills
- educational purposes
- modular - easy to add or remove features to make code size just right
- avoid size computations whenever it's possible (e.g. slide gap feature)- in more complex libraries they are hard to handle; it would also cause this tutorial to be too long
- the component should be easy to understand and scale so I can use it in future projects instead of including another full of features spaceship that in the end won't do much
DISCLAIMER: Use at your own risk.
1 1 | class Carousel { |
2 2 | constructor(containerReference, options) { |
3 3 | this.container = containerReference; |
4 4 | } |
5 5 | } |
6 6 | |
7 7 | const carouselElement = document.queryElement('.carousel'); |
8 8 | const myCarousel = new Carousel(); |
HTML Structure and CSS
<div class="carousel">
<div class="carousel__slide">
<div class="carousel__slide__content">
</div>
</div>
<div class="carousel__slide">
<div class="carousel__slide__content">
</div>
</div>
<div class="carousel__slide">
<div class="carousel__slide__content">
</div>
</div>
</div>
I prefer BEM CSS because it's easy to spot given components in huge CSS files. Our simple slider cares about slide width and container translateX position only so it doesn't matter if we are going to use CSS grids or flexbox. For a single row grid, flexbox is enough:
.carousel {
display: flex;
flex-direction: row;
height: 200px;
}
.carousel__slide {
position: relative;
flex: 0 0 100%;
padding: 10px;
}
.carousel__slide__content {
background: #c6b9ad;
border-radius: 10px;
width: 100%;
height: 100%;
}
Module Pattern Object Oriented Approach
Three months ago when I wrote the first paragraph of this tutorial, I was confident that Module Pattern will do the job. Additionally, I have studied Webpack Universal Module. It was fine because the first iteration of this project was relatively small. After I passed this article for a review to my colleagues I was told there are features missing that would be nice to have. Fair enough 😄
I have quickly understood that I am not creating easy to read and modular solution but a scary monolith. That was a time to play around with extending a base module.
...
Sounds familiar? I was thinking about this Carousel as an object the whole time and tried to write it as it's not. Object-Oriented Programming is all about thinking about things as if they are objects. That moment I've grabbed my hand grenade and threw it on the code. The course of true love code never did run smooth.
Composition over Inheritance
@@ -3,6 +3,13 @@ | |
3 3 | this.container = containerReference; |
4 4 | } |
5 5 | } |
6 6 | |
7 | +const SlideTo = Carousel => class extends Carousel {} |
8 | +const Drag = Carousel => class extends Carousel {} |
9 | +const Snap = Carousel => class extends Carousel {} |
10 | +const Loop = Carousel => class extends Carousel {} |
11 | + |
12 | +const DragSnapCarousel = Loop(Snap(Drag(SlideTo(Carousel)))) |
13 | + |
7 14 | const carouselElement = document.queryElement('.carousel'); |
8 | -const myCarousel = new Carousel(); |
15 | +const myCarousel = new DragSnapCarousel(); |
Inheritance doesn't work with the idea of the modules. Composition is what we are looking for here. It's easy to understand. A car has an engine, wheels, gearbox... The same goes for the carousel and its features: slideTo, drag, animation, and snap. We compose features into the final object. Some are not needed so we don't need to keep them in our code. More importantly, javascript has native class mixins that help us to program this way.
Solving nested syntax problem
Mixins are simple unary functions. In the example above you can see a function composition. Function composition is an approach where the result of one function is passed on to the next function, which is passed to another until the final function is executed for the final result. The problem is "))))))" part.
Let's create a util compose function that will help make it a bit prettier. Note that the order of arguments still matters.
@@ -1,4 +1,10 @@ | |
1 | +function compose(...functions) { |
2 | + return function(args) { |
3 | + return functions.reduceRight((arg, fn) => fn(arg), args); |
4 | + } |
5 | +} |
6 | + |
1 7 | class Carousel { |
2 8 | constructor(containerReference, options) { |
3 9 | this.container = containerReference; |
4 10 | } |
@@ -8,8 +14,8 @@ | |
8 14 | const Drag = Carousel => class extends Carousel {} |
9 15 | const Snap = Carousel => class extends Carousel {} |
10 16 | const Loop = Carousel => class extends Carousel {} |
11 17 | |
12 | -const DragSnapCarousel = Loop(Snap(Drag(SlideTo(Carousel)))) |
18 | +const DragSnapCarousel = compose(Loop, Snap, Drag, SlideTo)(Carousel) |
13 19 | |
14 20 | const carouselElement = document.queryElement('.carousel'); |
15 | -const myCarousel = new DragSnapCarousel(); |
21 | +const myCarousel = new DragSnapCarousel(); |
Layout reflow
The layout is where the browser calculates the positions and geometries of each DOM element. It is a render-blocking operation so it will cause a drop of FPS for a fraction of time. It's good to understand CSS and JavaScript specifics to avoid costly and frequent reflows. In order to do so, you can avoid complex and huge HTML structures and CSS selectors, optimize JS painting UI logic or avoid certain methods.

Worth noting: I was not able to reproduce any rendering issue using elem.getBoundingClientRect() method or elem.offsetWidth in Chrome 92. It seems browsers nowadays are smart enough to avoid useless rendering work and technology has changed a lot since then. More on that later.
In short - changing initial render values such as dimensions will cause reflow. Layout trashing is the most common performance problem in dynamic web applications and it causes a terrible user experience. It will make your app feel slow, thus creating a low-quality feeling.
CSS transforms were created to help developers make performant animations. The layout is skipped in the pixel pipeline when only transform values are changing, that's why it's recommended to use XYZ translations instead of left/top CSS rules.
Base Class
@@ -4,18 +4,87 @@ | |
4 4 | } |
5 5 | } |
6 6 | |
7 7 | class Carousel { |
8 | + |
8 9 | constructor(containerReference, options) { |
9 10 | this.container = containerReference; |
11 | + |
12 | + this.state = { |
13 | + isMouse: false, |
14 | + currentPosition: 0, |
15 | + offset: 0, |
16 | + size: { |
17 | + width: 0, |
18 | + slidePercentageWidth: 0 |
19 | + } |
20 | + }; |
21 | + |
22 | + const defaults = {} |
23 | + |
24 | + this.sliderOptions = { ...defaults, ...options } |
25 | + |
26 | + this.#countSlides(); |
27 | + this.#setCarouselSizes(); |
10 28 | } |
29 | + |
30 | + #countSlides() { |
31 | + const slides = this.container.querySelectorAll('.carousel__slide'); |
32 | + this.state.slidesCount = slides.length; |
33 | + } |
34 | + |
35 | + #getMaxWidth() { |
36 | + const { slidesCount } = this.state; |
37 | + const { slidePercentageWidth } = this.#measureCarousel(); |
38 | + return slidesCount * slidePercentageWidth; |
39 | + } |
40 | + |
41 | + #getOffsetHighestPoint() { |
42 | + const { slidesCount } = this.state; |
43 | + const { slidePercentageWidth } = this.#measureCarousel(); |
44 | + return (slidesCount - 1) * slidePercentageWidth; |
45 | + } |
46 | + |
47 | + #measureContainerElement() { |
48 | + const { width: containerWidth } = this.container.getBoundingClientRect(); |
49 | + return containerWidth; |
50 | + } |
51 | + |
52 | + #measureSlide() { |
53 | + const { width: slideWidth } = this.container.firstElementChild.getBoundingClientRect(); |
54 | + return slideWidth; |
55 | + } |
56 | + |
57 | + #measureCarousel() { |
58 | + const containerWidth = this.#measureContainerElement(); |
59 | + const slideWidth = this.#measureSlide(); |
60 | + const slidePercentageWidth = parseInt(((slideWidth / containerWidth) * 100).toFixed(0), 10); |
61 | + |
62 | + return { containerWidth, slidePercentageWidth } |
63 | + } |
64 | + |
65 | + #setCarouselSizes() { |
66 | + const { containerWidth, slidePercentageWidth } = this.#measureCarousel(); |
67 | + const maxWidth = this.#getMaxWidth(); |
68 | + const offsetMax = this.#getOffsetHighestPoint(); |
69 | + this.state.size = { |
70 | + maxWidth, |
71 | + offsetMax, |
72 | + width: containerWidth, |
73 | + slidePercentageWidth |
74 | + } |
75 | + } |
76 | + |
77 | + resize() { |
78 | + this.#setCarouselSizes(); |
79 | + } |
11 80 | } |
12 81 | |
13 82 | const SlideTo = Carousel => class extends Carousel {} |
14 83 | const Drag = Carousel => class extends Carousel {} |
15 84 | const Snap = Carousel => class extends Carousel {} |
16 85 | const Loop = Carousel => class extends Carousel {} |
17 86 | |
18 | -const DragSnapCarousel = compose(Loop, Snap, Drag, SlideTo)(Carousel) |
87 | +const DragSnapCarousel = compose()(Carousel) |
19 88 | |
20 89 | const carouselElement = document.queryElement('.carousel'); |
21 90 | const myCarousel = new DragSnapCarousel(); |
The purpose of the base class is to provide methods and contain logic related to Carousel geometry. Some of the methods are just sub-functions of the other ones and there is no need to expose them to the outside. This is why I have private methods. Private fields are relatively new -> browser support is good but could be better. It's worth noting that it works only on Safari 14+ so choose wisely. You can always replace '#' with '' and it's going to work or use babel if you like. Either way, some minification tools are going to print errors here.
The most important methods are #setCarouselSizes() which is responsible for setting up the initial state and resize() that you should call when the window changed its size. All other methods are the result of splitting code into smaller chunks because functions should be short and included instructions should be happening on the same level of abstraction.
- #countSlides() - plenty of times we need a number of slides in the carousel for calculations
- #getMaxWidth() - used for determining carousel width in percent value
- #getOffsetHighestPoint() - what % offset do we have to set in order to display the last slide?
- #measureContainerElement() - low level carousel measurement (in pixels)
- #measureSlide() - low level slide measurement (in pixels)
- #measureCarousel() - one level higher measurement. Will return slide percentage value.
- #setCarouselSizes() - sets all sizes to the state object
During my work on this code, I did my best in naming methods and variables in order to keep the code easy to understand. If without this article you cannot understand 70% of the code then clearly I have failed 😆
Simple state management
Nothing special here. We will use this object for storing temporary data (status/state) and settings. Pub/sub pattern here would look nice but it's not particularly necessary. I admit that I don't feel like this state management will work in the long run. Definitely a thing to reconsider in the future.
A history of my experiments with IntersectionObserver API hack
For transforms, we are going to use % values. You should avoid pixel units for better responsiveness. Because mouse event returns pixel value for position, at some point we will have to calculate percents based on that and slider container size. I was 100% sure that elem.getBoundingClientRect() will cause reflow so I challenged myself to not even think about using it.
@@ -53,8 +53,19 @@ | |
53 53 | const { width: slideWidth } = this.container.firstElementChild.getBoundingClientRect(); |
54 54 | return slideWidth; |
55 55 | } |
56 56 | |
57 | + #measureContainerIO() { |
58 | + const observer = new IntersectionObserver(entries => { |
59 | + for (const entry of entries) { |
60 | + const { width, height } = entry.boundingClientRect; |
61 | + state.size = { width, height }; |
62 | + } |
63 | + observer.disconnect(); |
64 | + }) |
65 | + observer.observe(this.container); |
66 | + } |
67 | + |
57 68 | #measureCarousel() { |
58 69 | const containerWidth = this.#measureContainerElement(); |
59 70 | const slideWidth = this.#measureSlide(); |
60 71 | const slidePercentageWidth = parseInt(((slideWidth / containerWidth) * 100).toFixed(0), 10); |
And here comes the first hack. Hacks should be avoided as a good practice because they usually have their own problems. Sending measurement jobs to another thread makes this operation asynchronous in nature so there is a challenge associated with that choice. I have noticed that slider sometimes initializes faster than measurement happens but because it's a rare bug (on my machine) I decided to ignore that issue.
IntersectionObserver returns boundClientRect object containing size of observed element. IO works on a different thread and I found it brilliant to use it for size calculation. But then I decided to test my solution against elem.getBoundingClientRect() and guess what:

No difference at all. Lesson 1:
I spent some time on it - not gonna lie 😅, but it was fun. Never be 100% sure about anything.
Drag feature
Mouse & Touch Event Listeners
There are three interactions that from the slider perspective matter - pointer down, pointer up, and pointer move.
@@ -53,19 +53,8 @@ | |
53 53 | const { width: slideWidth } = this.container.firstElementChild.getBoundingClientRect(); |
54 54 | return slideWidth; |
55 55 | } |
56 56 | |
57 | - #measureContainerIO() { |
58 | - const observer = new IntersectionObserver(entries => { |
59 | - for (const entry of entries) { |
60 | - const { width, height } = entry.boundingClientRect; |
61 | - state.size = { width, height }; |
62 | - } |
63 | - observer.disconnect(); |
64 | - }) |
65 | - observer.observe(this.container); |
66 | - } |
67 | - |
68 57 | #measureCarousel() { |
69 58 | const containerWidth = this.#measureContainerElement(); |
70 59 | const slideWidth = this.#measureSlide(); |
71 60 | const slidePercentageWidth = parseInt(((slideWidth / containerWidth) * 100).toFixed(0), 10); |
@@ -90,12 +79,58 @@ | |
90 79 | } |
91 80 | } |
92 81 | |
93 82 | const SlideTo = Carousel => class extends Carousel {} |
83 | + |
94 | -const Drag = Carousel => class extends Carousel {} |
84 | +const Drag = Carousel => class extends Carousel { |
85 | + constructor(containerReference, options) { |
86 | + super(containerReference, options); |
87 | + this.#attachActivationEvents(); |
88 | + } |
89 | + |
90 | + #attachActivationEvents() { |
91 | + const { container } = this; |
92 | + if (container) { |
93 | + container.addEventListener('contextmenu', this.handleUp); |
94 | + container.addEventListener('mousedown', this.#handleDown); |
95 | + container.addEventListener('touchcancel', this.handleUp); |
96 | + container.addEventListener('touchstart', this.#handleDown, { passive: true }); |
97 | + } |
98 | + } |
99 | + |
100 | + #attachInteractionEvents() { |
101 | + const node = !this.state.isMouse ? this.container : document; |
102 | + if (node) { |
103 | + node.addEventListener('mousemove', this.#handleMove); |
104 | + node.addEventListener('mouseup', this.handleUp); |
105 | + node.addEventListener('touchend', this.handleUp); |
106 | + node.addEventListener('touchmove', this.#handleMove, { passive: true }); |
107 | + } |
108 | + } |
109 | + |
110 | + #handleDown = (event) => { |
111 | + } |
112 | + |
113 | + #handleMove = (event) => { |
114 | + } |
115 | + |
116 | + removeInteractionEvents() { |
117 | + const node = !this.state.isMouse ? this.container : document; |
118 | + if (node) { |
119 | + node.removeEventListener('mousemove', this.#handleMove); |
120 | + node.removeEventListener('mouseup', this.handleUp); |
121 | + node.removeEventListener('touchend', this.handleUp); |
122 | + node.removeEventListener('touchmove', this.#handleMove, { passive: true }); |
123 | + } |
124 | + } |
125 | + |
126 | + handleUp = (event) => { |
127 | + } |
128 | +} |
129 | + |
95 130 | const Snap = Carousel => class extends Carousel {} |
96 131 | const Loop = Carousel => class extends Carousel {} |
97 132 | |
98 | -const DragSnapCarousel = compose()(Carousel) |
133 | +const DragSnapCarousel = compose(Drag)(Carousel) |
99 134 | |
100 135 | const carouselElement = document.queryElement('.carousel'); |
101 136 | const myCarousel = new DragSnapCarousel(); |
About this line:
const node = !this.state.isMouse ? this.container : document;
In comparison with my previous attempts, this is the crucial line. Dragging the mouse and leaving the container/browser window was causing a lot of odd behaviors and required extra if statements. Turns out that if you attach a listener to a document instead of a container, the mouse-drag will continue registering values outside the browser window:
Because of this line, there is a need to split attachEvents() function into two groups: dragging feature activation and interaction group. We don't want to add too many listeners* and pollute document element so there is a removeInteractionEvents() function as well.
* - Memory Leaks using named event listeners…
…are not an issue. In this experiment, I tried to break and benchmark as many things as I could but was unsuccessful in this approach too. Chrome didn't let me add too many exact same listeners. In fact, it let me add only one of each type:

And memory analysis didn't show any leak happening:

Drag event handlers
The idea is simple - on pointer down, we initialize "interaction events" which are going to work only if activation events were initialized. Because node can be a document, we don't want these event handlers to work in the background all the time, so we make sure that processing happens when dragging on our container does. Pointer move will file #handleDrag(eventData) function and on pointer, up we are removing interactionEvents and update state.
@@ -107,11 +107,18 @@ | |
107 107 | } |
108 108 | } |
109 109 | |
110 110 | #handleDown = (event) => { |
111 | + const { type } = event; |
112 | + this.state.isMouse = type === 'mousedown'; |
113 | + this.#attachInteractionEvents(); |
114 | + this.#startDrag(); |
115 | + this.#setCurrentDragPosition(event); |
111 116 | } |
112 117 | |
113 118 | #handleMove = (event) => { |
119 | + const { isDragging } = this.state; |
120 | + if (isDragging) this.#handleDrag(event); |
114 121 | } |
115 122 | |
116 123 | removeInteractionEvents() { |
117 124 | const node = !this.state.isMouse ? this.container : document; |
@@ -123,8 +130,9 @@ | |
123 130 | } |
124 131 | } |
125 132 | |
126 133 | handleUp = (event) => { |
134 | + this.#drag(); |
127 135 | } |
128 136 | } |
129 137 | |
130 138 | const Snap = Carousel => class extends Carousel {} |
Details
As you have probably noticed - not all of the methods are private. Some of them require to be overwritten later and that cannot be done with private fields. That's why I still question the decision on using private fields.
@@ -1,4 +1,20 @@ | |
1 | +function delta(oldPoint, newPoint) { |
2 | + return newPoint - oldPoint; |
3 | +} |
4 | + |
5 | +function readPoint(event, isMouse) { |
6 | + return isMouse ? event.screenX : (event.changedTouches && event.changedTouches[0].clientX) || event.screenX; |
7 | +} |
8 | + |
9 | +function toPercent(value, width) { |
10 | + return (value / width) * 100; |
11 | +} |
12 | + |
13 | +function invert(value) { |
14 | + return value * -1; |
15 | +} |
16 | + |
1 17 | function compose(...functions) { |
2 18 | return function(args) { |
3 19 | return functions.reduceRight((arg, fn) => fn(arg), args); |
4 20 | } |
@@ -81,13 +97,25 @@ | |
81 97 | |
82 98 | const SlideTo = Carousel => class extends Carousel {} |
83 99 | |
84 100 | const Drag = Carousel => class extends Carousel { |
101 | + |
85 102 | constructor(containerReference, options) { |
86 103 | super(containerReference, options); |
87 104 | this.#attachActivationEvents(); |
88 105 | } |
89 106 | |
107 | + #animateRough() { |
108 | + const { offset } = this.state; |
109 | + this.container.style.transition = ''; |
110 | + this.container.style.transform = `translateX(${offset}%)`; |
111 | + } |
112 | + |
113 | + #animateSmoothly(offset) { |
114 | + this.container.style.transition = 'transform ease-in-out .8s'; |
115 | + this.container.style.transform = `translateX(${offset}%)`; |
116 | + } |
117 | + |
90 118 | #attachActivationEvents() { |
91 119 | const { container } = this; |
92 120 | if (container) { |
93 121 | container.addEventListener('contextmenu', this.handleUp); |
@@ -106,16 +134,44 @@ | |
106 134 | node.addEventListener('touchmove', this.#handleMove, { passive: true }); |
107 135 | } |
108 136 | } |
109 137 | |
138 | + #calculateDragOffset(event) { |
139 | + const { |
140 | + currentPosition, |
141 | + isMouse, |
142 | + pointerPosition, |
143 | + size: { |
144 | + width: containerWidth |
145 | + } |
146 | + } = this.state; |
147 | + |
148 | + const currentPoint = readPoint(event, isMouse); |
149 | + const pointerOffsetMade = delta(pointerPosition, currentPoint); |
150 | + const pointerOffsetInPercents = toPercent(pointerOffsetMade, containerWidth); |
151 | + const carouselOffset = currentPosition + pointerOffsetInPercents; |
152 | + |
153 | + this.state.offset = carouselOffset; |
154 | + } |
155 | + |
156 | + #drag() { |
157 | + this.removeInteractionEvents(); |
158 | + this.#stopDrag(); |
159 | + } |
160 | + |
110 161 | #handleDown = (event) => { |
111 162 | const { type } = event; |
112 163 | this.state.isMouse = type === 'mousedown'; |
113 164 | this.#attachInteractionEvents(); |
114 165 | this.#startDrag(); |
115 166 | this.#setCurrentDragPosition(event); |
116 167 | } |
117 168 | |
169 | + #handleDrag = (event) => { |
170 | + this.#calculateDragOffset(event); |
171 | + this.handleAnimation(); |
172 | + } |
173 | + |
118 174 | #handleMove = (event) => { |
119 175 | const { isDragging } = this.state; |
120 176 | if (isDragging) this.#handleDrag(event); |
121 177 | } |
@@ -129,8 +185,36 @@ | |
129 185 | node.removeEventListener('touchmove', this.#handleMove, { passive: true }); |
130 186 | } |
131 187 | } |
132 188 | |
189 | + #setCurrentDragPosition(event) { |
190 | + const { isMouse } = this.state; |
191 | + this.state.pointerPosition = readPoint(event, isMouse); |
192 | + } |
193 | + |
194 | + #startDrag() { |
195 | + this.state.isDragging = true; |
196 | + } |
197 | + |
198 | + #stopDrag() { |
199 | + const { offset } = this.state; |
200 | + this.state.isMouse = false; |
201 | + this.state.isDragging = false; |
202 | + this.state.currentPosition = offset; |
203 | + } |
204 | + |
205 | + handleAnimation(inputOffset) { |
206 | + this.state.raf = requestAnimationFrame(() => { |
207 | + if (typeof inputOffset !== 'undefined') { |
208 | + this.#animateSmoothly(inputOffset); |
209 | + } else { |
210 | + this.#animateRough(); |
211 | + } |
212 | + }) |
213 | + |
214 | + return typeof inputOffset !== 'undefined' ? inputOffset : offset; |
215 | + } |
216 | + |
133 217 | handleUp = (event) => { |
134 218 | this.#drag(); |
135 219 | } |
136 220 | } |
- #animateRough() - a method that sets a new offset at the right time. It's called rough because I do not apply any easings here. Unfortunately, I will create some kind of a dependency later by creating a #animateSmoothly() method .
- #calculateDragOffset(event) - based on input data (event) and state, calculate what new offset in % should be. Or in other words - calculate what offset we have just made and add it to the current one.
I have created some utils as well. Function names like delta() or toPercent() are telling reader intentions behind it. I could have created a lot of one-liners like value * PERCENT, but it takes a little bit more time at first look to answer the "why?" question. Well-written code is the code that can be understood by the worst developer you know. What is self-documenting code and can it replace well documented code? [closed]
Animations
Again, performance is the #1 priority in this project. There is no other choice for animations than using window.requestAnimationFrame(). It comes with a few huge benefits (in simple words):
- Event Loop is too fast for animations. rAF will adjust to monitor refresh rate (usually 60Hz ~ 16ms)
- The browser will optimize all concurrent animations together into a single reflow and repaint cycle
- Animations won't work in the background
- Browser will put code from rAF function to a queue and wait for a less busy cycle to execute
- Those callbacks will not interrupt other tasks
This way we are going to minimize battery drain or keep CPU fans spinning slowly 🙂
For moving on X-axis let's use translations. Top/right/left/bottom or height/width CSS properties were not made for animations. Try to not animate using those properties. You can see that animate() function takes a snap boolean value as a parameter. We will get back to it later. For now what important is the fact that we don't want any CSS easing to happen during the drag event. Otherwise, we are going to experience a sort of laggy fluid effect.
Is will-change CSS property still a thing?

It's recommended to use will-change CSS property for components that may change in the future so the browser may do some optimizations in advance. The image above shows three tests: will-change on wrapper element, animated element, and no property set. Tried the translate3d property at first but then realized that it's handled by GPU already, so moved to translateX to force CPU work. I have spent a lot of time trying to find any evidence that there is an improvement and ended up looking for correlations between the test results and the number of coffee drank but that's it. I have used puppeteer to throttle down the CPU by 40 times to spot anything interesting happening. If you can tell which graph stands for a given test let me know 😉 Maybe Chrome 92 doesn't need any CSS optimization hacks anymore?
So is will-change still a thing? I'm not giving any answers but I think I know one. Let me know what do you think.
Snap functionality
The challenge is to detect when we are in the margin range and based on that information we need to animate to the rounded offset value. The highest level method here is the snap() function which is made of three parts - based on input calculate new offset, handle animation and finish the snap process.
@@ -13,8 +13,16 @@ | |
13 13 | function invert(value) { |
14 14 | return value * -1; |
15 15 | } |
16 16 | |
17 | +function isNegative(number) { |
18 | + return !Object.is(Math.abs(number), +number); |
19 | +} |
20 | + |
21 | +function easeInOutCubic(x) { |
22 | + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; |
23 | +} |
24 | + |
17 25 | function compose(...functions) { |
18 26 | return function(args) { |
19 27 | return functions.reduceRight((arg, fn) => fn(arg), args); |
20 28 | } |
@@ -218,11 +226,154 @@ | |
218 226 | this.#drag(); |
219 227 | } |
220 228 | } |
221 229 | |
222 | -const Snap = Carousel => class extends Carousel {} |
230 | +const Snap = Carousel => class extends Carousel { |
231 | + #getOffsetMade(dataSource) { |
232 | + const { |
233 | + isMouse, |
234 | + pointerPosition, |
235 | + size: { |
236 | + width: containerWidth |
237 | + } |
238 | + } = this.state; |
239 | + |
240 | + const currentPoint = readPoint(dataSource, isMouse); |
241 | + const offsetMade = delta(pointerPosition, currentPoint); |
242 | + return toPercent(offsetMade, containerWidth); |
243 | + } |
244 | + |
245 | + #detectInMarginOffset(offsetMade, marginSize) { |
246 | + return Math.abs(offsetMade) <= marginSize; |
247 | + } |
248 | + |
249 | + #finishSnap(offset) { |
250 | + this.removeInteractionEvents(); |
251 | + this.state.isDragging = false; |
252 | + this.state.currentPosition = offset; |
253 | + this.state.isMouse = false; |
254 | + requestAnimationFrame(() => cancelAnimationFrame(this.state.raf)); |
255 | + } |
256 | + |
257 | + #getOffsetInSlides(offset) { |
258 | + const { slidePercentageWidth } = this.state.size; |
259 | + const slideSize = offset / slidePercentageWidth; |
260 | + return slideSize < 0 ? Math.ceil(slideSize) : Math.floor(slideSize); |
261 | + } |
262 | + |
263 | + #getSlideToValue(slideBy, inMargin) { |
264 | + const { slidePercentageWidth } = this.state.size; |
265 | + const backwardMovement = isNegative(slideBy); |
266 | + const MOVE_OFFSET = 1; |
267 | + |
268 | + const snapForward = () => this.state.currentPosition + (slideBy - MOVE_OFFSET) * slidePercentageWidth; |
269 | + const snapBackward = () => this.state.currentPosition + (slideBy + MOVE_OFFSET) * slidePercentageWidth; |
270 | + const stay = () => this.state.currentPosition |
271 | + |
272 | + if (inMargin) { |
273 | + return stay(); |
274 | + } else if (backwardMovement) { |
275 | + return snapForward(); |
276 | + } else { |
277 | + return snapBackward(); |
278 | + } |
279 | + } |
280 | + |
281 | + #getSnapOffset(event) { |
282 | + const { slideCount } = this; |
283 | + |
284 | + const offsetMade = this.#getOffsetMade(event); |
285 | + const isInMargin = this.#detectInMarginOffset(offsetMade, 10); |
286 | + const slideBy = this.#getOffsetInSlides(offsetMade); |
287 | + const calculatedOffset = this.#getSlideToValue(slideBy, isInMargin); |
288 | + const limitedOffset = this.#validateSlideToValue(calculatedOffset); |
289 | + const offset = slideCount === Infinity ? calculatedOffset : limitedOffset; |
290 | + return offset; |
291 | + } |
292 | + |
293 | + #stopAnimation(finalOffset) { |
294 | + cancelAnimationFrame(this.state.snapRaf); |
295 | + this.state.offset = finalOffset; |
296 | + } |
297 | + |
298 | + #validateSlideToValue(inputOffset) { |
299 | + const { |
300 | + size: { |
301 | + slidePercentageWidth, |
302 | + maxWidth, |
303 | + } |
304 | + } = this.state; |
305 | + |
306 | + const CAROUSEL_START_POINT = 0; |
307 | + const GAP_ON_END = 100 - slidePercentageWidth; |
308 | + const CAROUSEL_END_POINT = -(maxWidth) + slidePercentageWidth + GAP_ON_END; |
309 | + |
310 | + if (inputOffset > CAROUSEL_START_POINT) { |
311 | + return 0; |
312 | + } else if (inputOffset < CAROUSEL_END_POINT) { |
313 | + return CAROUSEL_END_POINT; |
314 | + } else { |
315 | + return inputOffset; |
316 | + } |
317 | + } |
318 | + |
319 | + animationHook() {} |
320 | + |
321 | + handleSnapAnimation(offset) { |
322 | + const DURATION = 1000; |
323 | + const animationInput = this.state.offset; |
324 | + const animationTarget = offset; |
325 | + |
326 | + let animationFrame = null; |
327 | + |
328 | + let stop = false; |
329 | + let start = null |
330 | + |
331 | + const calculateFrame = (easingPoint) => animationInput + (animationTarget - animationInput) * easingPoint; |
332 | + |
333 | + const draw = () => { |
334 | + const now = Date.now(); |
335 | + const point = (now - start) / DURATION; |
336 | + const easingPoint = easeInOutCubic(point); |
337 | + |
338 | + if (stop) { |
339 | + this.#stopAnimation(animationFrame); |
340 | + return; |
341 | + } |
342 | + |
343 | + animationFrame = calculateFrame(easingPoint); |
344 | + |
345 | + if (now - start >= DURATION) { |
346 | + stop = true; |
347 | + const roundedEndFrame = Math.round(animationFrame); |
348 | + animationFrame = roundedEndFrame; |
349 | + }; |
350 | + this.animationHook(animationFrame); |
351 | + this.container.style.transform = `translateX(${animationFrame}%)`; |
352 | + this.state.snapRaf = requestAnimationFrame(draw) |
353 | + } |
354 | + |
355 | + const startAnimation = () => { |
356 | + start = Date.now(); |
357 | + draw(start) |
358 | + } |
359 | + |
360 | + startAnimation(); |
361 | + } |
362 | + |
363 | + handleUp = (event) => { |
364 | + this.snap(event); |
365 | + } |
366 | + |
367 | + snap(event) { |
368 | + const offset = this.#getSnapOffset(event); |
369 | + const offsetSet = this.handleAnimation(offset); |
370 | + this.#finishSnap(offsetSet); |
371 | + } |
372 | +} |
373 | + |
223 374 | const Loop = Carousel => class extends Carousel {} |
224 375 | |
225 | -const DragSnapCarousel = compose(Drag)(Carousel) |
376 | +const DragSnapCarousel = compose(Snap, Drag)(Carousel) |
226 377 | |
227 378 | const carouselElement = document.queryElement('.carousel'); |
228 379 | const myCarousel = new DragSnapCarousel(); |
Let's break down the calculation logic (#getSnapOffset(event) method) into small steps:
- How many slides we dragged over? Was it just a click?
- We need to find how big the offset was (in percent)
Then based on known slide width we calculate how much in slides it is. Both Math.ceil() and Math.floor() are there because offset can be negative.
?
- Are we in the snap margin? How to tell when do we lock slider to the given slide or go to the next/previous one?
- In the isInMargin constant, I'm checking if offsetMade is higher than 10 magic value which is a 10% margin. That means if a slide was moved by 10% of its width we want slide next. At this point I don't care if it's negative or positive value, so to make it simpler let's wrap offsetMade in the Math.abs()
- In the isInMargin constant, I'm checking if offsetMade is higher than 10 magic value which is a 10% margin. That means if a slide was moved by 10% of its width we want slide next. At this point I don't care if it's negative or positive value, so to make it simpler let's wrap offsetMade in the Math.abs()
- Let's calculate the target position in order to stop the animation at the beginning of the previous, next, or current slide. (calculatedOffset)
- There are 3 possible options here, snap to the current, next, or previous slide.
- If offset is in margin range we want to return the current position (which should be a rounded value)
- If the offset is not in margin and backward movement was detected (from right to left), we want to snap to the next slide (slideForward()) e
- slideBy can be 0 or -0 value. Backward movement is a negative value obviously but the fact that it's 0/-0 is quite problematic. I have used isNegative() helper for that.
- We need to make sure that we won't slide after the last slide. If slide width will be less than 100%, then we need to take into account the additional white gap after the last slide.
- Define CAROUSEL_START_POINT.
- GAP_ON_END is a gap that we want to get rid of.
- CAROUSEL_END_POINT is the distance at which the last slide will end
- Implement logic to decide if we should stick to the CAROUSEL_START_POINT, END_POINT or let the offset be the input offset
- If slideCount is not Infinite (Loop module) method should return validated offset. Otherwise sliding "outside the real" carousel is fine.
Done 🎉😄!
Interesting here is a 3.4 step. In ordinary arithmetic, the number 0 does not have a sign, so that −0, +0, and 0 are identical. However, in computing, some number representations allow for the existence of two zeros, often denoted by −0 (negative zero) and +0 (positive zero), regarded as equal by the numerical comparison operations but with possible different behaviors in particular operations.
slideTo(n) method
@@ -102,10 +102,64 @@ | |
102 102 | this.#setCarouselSizes(); |
103 103 | } |
104 104 | } |
105 105 | |
106 | -const SlideTo = Carousel => class extends Carousel {} |
106 | +const SlideTo = Carousel => class extends Carousel { |
107 | + #animateSmoothly(offset) { |
108 | + this.container.style.transition = 'transform ease-in-out .2s'; |
109 | + this.container.style.transform = `translateX(${offset}%)`; |
110 | + } |
111 | + |
112 | + #endAnimation() { |
113 | + requestAnimationFrame(() => cancelAnimationFrame(this.state.raf)); |
114 | + } |
107 115 | |
116 | + #limitSlideTo({target, max}) { |
117 | + let slideTo = 0; |
118 | + if (target > max) { |
119 | + slideTo = max; |
120 | + } else if (target < 1) { |
121 | + slideTo = 1; |
122 | + } else { |
123 | + slideTo = target; |
124 | + } |
125 | + return slideTo; |
126 | + } |
127 | + |
128 | + #parseSlideTo(targetSlide) { |
129 | + const { |
130 | + slideCount, |
131 | + state: { |
132 | + size: { |
133 | + slidePercentageWidth |
134 | + } |
135 | + } |
136 | + } = this; |
137 | + |
138 | + const limitedAnimateTo = this.#limitSlideTo({target: targetSlide, max: slideCount}); |
139 | + const ARRAY_OFFSET = 1; |
140 | + |
141 | + return (limitedAnimateTo - ARRAY_OFFSET) * invert(slidePercentageWidth); |
142 | + } |
143 | + |
144 | + handleAnimation(offset) { |
145 | + this.state.raf = requestAnimationFrame(() => { |
146 | + this.#animateSmoothly(offset); |
147 | + }) |
148 | + |
149 | + return offset; |
150 | + } |
151 | + |
152 | + slideTo(targetSlide) { |
153 | + const validatedTarget = this.#parseSlideTo(targetSlide); |
154 | + |
155 | + this.handleAnimation(validatedTarget); |
156 | + this.state.currentPosition = validatedTarget; |
157 | + |
158 | + this.#endAnimation(); |
159 | + } |
160 | +} |
161 | + |
108 162 | const Drag = Carousel => class extends Carousel { |
109 163 | |
110 164 | constructor(containerReference, options) { |
111 165 | super(containerReference, options); |
@@ -218,9 +272,9 @@ | |
218 272 | this.#animateRough(); |
219 273 | } |
220 274 | }) |
221 275 | |
222 | - return typeof inputOffset !== 'undefined' ? inputOffset : offset; |
276 | + return typeof inputOffset !== 'undefined' ? inputOffset : this.state.offset; |
223 277 | } |
224 278 | |
225 279 | handleUp = (event) => { |
226 280 | this.#drag(); |
@@ -372,8 +426,8 @@ | |
372 426 | } |
373 427 | |
374 428 | const Loop = Carousel => class extends Carousel {} |
375 429 | |
376 | -const DragSnapCarousel = compose(Snap, Drag)(Carousel) |
430 | +const DragSnapCarousel = compose(Snap, Drag, SlideTo)(Carousel) |
377 431 | |
378 432 | const carouselElement = document.queryElement('.carousel'); |
379 433 | const myCarousel = new DragSnapCarousel(); |
Each self-respecting slider should have slideTo() functionality 😄. At least if you plan to have navigation dots/arrows. Because slideTo() is a public function that accepts single parameter, it must hold slide limitation logic.
- #limitSlideTo() - we need to check if the input value is in the slide amount range. We shouldn't be able to move slide 999 if there are only 3 slides in carousel. Here I have used object as input. I wanted to test if that form of passing arguments is more developer-friendly. The answer is - maybe not in this case but there's something cool about it 😄
- #parseSlideTo() - limit function wrapper. Used mostly to transform input value into % offset value including array index difference.
To infinity and beyond
Creating an infinite carousel is tricky and the trick lies in changing offset by a lot just in time as quickly as possible and (if the slide is less than 100% wide) changing the position of the first/last slide. First, we need to turn off first/last slide snap limitation. We do that by setting slide count to Infinity. In the snap module, we have a logic that will turn off limitations if the number of slides will be that big. Second - we want to fill the empty gap when we are at the ends of the carousel. When the movement to the beginning of the last slide is made we must in a fraction of the time change the carousel position back to 0 so it looks like nothing has changed. Here's the slow-motion animation that describes this process:
The whole magic starts in handleAnimation() method. The loop module has way different animation logic, so there is no option but to overwrite one of the fundamental methods in the project. Unfortunately because of that decision, I had to keep a dependency to other modules, and based on what's loaded and what was put in the input we run different logic.
@@ -424,10 +424,166 @@ | |
424 424 | this.#finishSnap(offsetSet); |
425 425 | } |
426 426 | } |
427 427 | |
428 | -const Loop = Carousel => class extends Carousel {} |
428 | +const Loop = Carousel => class extends Carousel { |
429 | + constructor(containerReference, options) { |
430 | + super(containerReference, options); |
431 | + this.slideCount = Infinity; |
432 | + } |
429 433 | |
434 | + #animateSmoothly(offset) { |
435 | + this.container.style.transition = 'transform ease-in-out .2s'; |
430 | -const DragSnapCarousel = compose(Snap, Drag, SlideTo)(Carousel) |
436 | + this.container.style.transform = `translateX(${offset}%)`; |
437 | + } |
431 438 | |
439 | + #animateRough(inputOffset) { |
440 | + const { offset } = this.state; |
441 | + const moveToOffset = typeof inputOffset !== 'undefined' ? inputOffset : offset; |
442 | + this.container.style.transition = ''; |
443 | + this.container.style.transform = `translateX(${moveToOffset}%)`; |
444 | + } |
445 | + |
446 | + #calcCurrentGapSize(offset) { |
447 | + const { size: { slidePercentageWidth, maxWidth } } = this.state; |
448 | + |
449 | + const GAP_ON_END = 100 - slidePercentageWidth; |
450 | + const CAROUSEL_END_POINT = invert(-(maxWidth) + slidePercentageWidth + GAP_ON_END); |
451 | + const invertedOffset = invert(offset); |
452 | + const carouselHasGap = invertedOffset >= CAROUSEL_END_POINT; |
453 | + |
454 | + if (carouselHasGap) { |
455 | + return invertedOffset - CAROUSEL_END_POINT; |
456 | + } else { |
457 | + return 0; |
458 | + } |
459 | + } |
460 | + |
461 | + #detectIfCarouselIsOnEnds(offset) { |
462 | + const CAROUSEL_START = 0; |
463 | + const CAROUSEL_END = this.#getCarouselEnd(); |
464 | + return (offset > CAROUSEL_START || offset <= -CAROUSEL_END - 1); |
465 | + } |
466 | + |
467 | + #fillGap(offset) { |
468 | + const isOnEnd = this.#detectIfCarouselIsOnEnds(offset); |
469 | + if (isOnEnd) { |
470 | + const gapSize = this.#calcCurrentGapSize(offset); |
471 | + const slideAmount = this.#getHowManySlidesFills(gapSize); |
472 | + this.#translateSlides(slideAmount); |
473 | + } else { |
474 | + this.#resetTranslation() |
475 | + } |
476 | + } |
477 | + |
478 | + #getHowManySlidesFills(gap) { |
479 | + const { slidePercentageWidth } = this.state.size; |
480 | + return Math.ceil(gap / slidePercentageWidth); |
481 | + } |
482 | + |
483 | + #getCarouselEnd() { |
484 | + const { state: { slidesCount, size: { slidePercentageWidth } } } = this; |
485 | + const GAP_ON_END = 100 - slidePercentageWidth; |
486 | + const END_OFFSET = (slidesCount - 1) * slidePercentageWidth; |
487 | + return (END_OFFSET - GAP_ON_END); |
488 | + } |
489 | + |
490 | + #overWriteOffset(offset) { |
491 | + const { |
492 | + size: { |
493 | + slidePercentageWidth, |
494 | + offsetMax, |
495 | + maxWidth |
496 | + } |
497 | + } = this.state; |
498 | + |
499 | + const rangeWithoutGap = invert(offsetMax - (100 - slidePercentageWidth)); |
500 | + const rangeWithGap = invert(maxWidth); |
501 | + const range = this.slideCount === Infinity ? rangeWithGap : rangeWithoutGap ; |
502 | + if (offset > 0) { |
503 | + return this.#processOffsetAboveZero(offset); |
504 | + } else if (offset < range ) { |
505 | + return this.#processOffsetBelowMax(offset); |
506 | + } else { |
507 | + return offset; |
508 | + } |
509 | + } |
510 | + |
511 | + #processOffsetAboveZero(offset) { |
512 | + const { maxWidth } = this.state.size; |
513 | + return invert(maxWidth - ((offset / (maxWidth)) % 1) * (maxWidth)); |
514 | + } |
515 | + |
516 | + #processOffsetBelowMax(offset) { |
517 | + const { maxWidth } = this.state.size; |
518 | + return invert(((invert(offset) / maxWidth) % 1) * maxWidth); |
519 | + } |
520 | + |
521 | + #resetTranslation() { |
522 | + const { container } = this; |
523 | + |
524 | + const carouselSlides = container.children; |
525 | + const slidesToReset = Array.from(carouselSlides); |
526 | + for(let i = 0; i < slidesToReset.length; i++ ) { |
527 | + slidesToReset[i].style.transform = `initial` |
528 | + } |
529 | + } |
530 | + |
531 | + #runLoopAnimation(offset) { |
532 | + this.#fillGap(offset); |
533 | + this.#animateRough(offset); |
534 | + } |
535 | + |
536 | + #handleSlideToAnimation(offset) { |
537 | + const { container } = this; |
538 | + this.#fillGap(offset); |
539 | + this.#animateSmoothly(offset); |
540 | + } |
541 | + |
542 | + #translateSlides(amountOfSlidesToMove) { |
543 | + const { container, state: { slidesCount } } = this; |
544 | + |
545 | + const PERCENT = 100; |
546 | + |
547 | + const carouselSlides = container.children; |
548 | + const slidesToMove = Array.from(carouselSlides); |
549 | + const carouselWidth = slidesCount * PERCENT; |
550 | + |
551 | + for(let i = 0; i < slidesToMove.length; i++ ) { |
552 | + if (i < amountOfSlidesToMove) { |
553 | + slidesToMove[i].style.transform = `translateX(${carouselWidth}%)` |
554 | + } else { |
555 | + slidesToMove[i].style.transform = `initial` |
556 | + } |
557 | + } |
558 | + } |
559 | + |
560 | + animationHook(offset) { |
561 | + this.#fillGap(offset); |
562 | + } |
563 | + |
564 | + handleAnimation(inputOffset) { |
565 | + const { offset } = this.state; |
566 | + const moveToOffset = typeof inputOffset !== 'undefined' ? inputOffset : offset; |
567 | + |
568 | + const newOffset = this.#overWriteOffset(moveToOffset); |
569 | + |
570 | + const snapModuleLoaded = this.handleSnapAnimation; |
571 | + const slideToModuleLoaded = this.slideTo; |
572 | + this.state.raf = requestAnimationFrame(() => { |
573 | + if (typeof inputOffset !== 'undefined' && snapModuleLoaded) { |
574 | + this.handleSnapAnimation(newOffset); |
575 | + } else if (typeof inputOffset !== 'undefined' && slideToModuleLoaded) { |
576 | + this.#handleSlideToAnimation(newOffset) |
577 | + } else { |
578 | + this.#runLoopAnimation(newOffset); |
579 | + } |
580 | + this.state.offset = newOffset; |
581 | + }) |
582 | + return newOffset; |
583 | + } |
584 | +} |
585 | + |
586 | +const DragSnapCarousel = compose(Loop, Snap, Drag, SlideTo)(Carousel) |
587 | + |
432 588 | const carouselElement = document.queryElement('.carousel'); |
433 589 | const myCarousel = new DragSnapCarousel(); |
What's new?
- #handleSlideToAnimation() - highly dependent on loop module function, that handles filling ending gaps when slideTo() is used.
- #runLoopAnimation - same as above but animates without easing functions.
The most important here is #fillGap() function that is responsible for the illusion shown above:
- #detectIfCarouselIsOnEnds() - Ends are green points on the illustration below. We find end point using #getCarouselEnd()
- If carousel offset is out of start-end range get the current white space (gap) size using #calcCurrentGapSize(). White space size is represented in % values.
- Then we need to #getHowManySlidesFills() that white space and save it in slideAmount variable.
- Finally, we move slides on ends to fill the gap. If carousel is not on ends we should #resetTranslation() of the slides.
But that's not all - we can't keep filling white space forever. In order to finish our magic trick, we need to go back to the "normal" offset range. If slideCount is infinite, and the offset value is bigger than maxWidth, we must quickly reset all translations and go back to the beginning or end of our carousel -> depending on a slide direction and position. The last part is done by #processOffset*() methods.
Wrapping up

So have we found it? I'd say it's great in many aspects but let's admit it - it's not perfect. I think we managed to meet all initial requirements. Most importantly mine and your component library just received another cool item, which I hope will be easy to scale for us. But that's not all, we have learned:
- ...how to create dragging feature using desktop and mobile events?
- a lot about performance
- what is and how to use WebpackUniversalModule?
- why hackish solutions are not great?
- that focusing too much on performance is not worth it (sometimes)
- in general, how sliders work?
Another advantage is that the carousel is lightweight because of it's modular nature. It's really hard to compare sliders with each other because they have different features. Here is our score:
- Base + Drag & Snap module -> minified and gzipped approx 1.5kB 🌳*
- Everything -> approx 2.5kB 🌳*
* - much depends on the conversion level and minification settings.

What's next?
I did not cover CSS Scroll Snap. CSS Scroll Snap should be more performant but it comes with different challenges. It will solve scroll limitation, but you will have to force the browser to hide scroll bars. Overflow will be hard to achieve. It's an interesting option - definitely a more performant one, but different. At the end of the day, it's up to your project requirements and your decision on which approach is more optimal.
I have spent many evenings and nights on this. Due to day-to-day responsibilities sometimes I was losing focus on this code. I'm still looking for improvements though 😉
All code is available on my GitHub. You can submit issues and PRs here: wisniewski94/modular-carousel
Also, I highly recommend checking out EmblaCarousel: davidcetinkaya/embla-carousel
Thanks for reading ✌!