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();

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.

Screenshot of IO vs DOM measurement method performance profile.
Google has great article about browser rendering pipeline: link

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:

Screenshot of IO vs DOM measurement method performance profile.
Screenshot of IO vs DOM measurement method performance profile. Image at the top is IntersectionObserver test.

No difference at all. Lesson 1:

Premature Optimization Is the Root of All Evil

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.

Dragging event demo.

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

Screenshot of IO vs DOM measurement method performance profile.
Screenshot of IO vs DOM measurement method performance profile. Image at the top is IntersectionObserver test.

And memory analysis didn't show any leak happening:

Screenshot of IO vs DOM measurement method performance profile.
Screenshot of IO vs DOM measurement method performance profile. Image at the top is IntersectionObserver test.

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]

Good code is its own best documentation

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.

Mouse & touch drag example.

Is will-change CSS property still a thing?

Screenshot of will-change css performance tests.
Will-change css performance tests.

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.

Slider with snap functionality. Images powered by picsum.
@@ -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:

  1. How many slides we dragged over? Was it just a click?
    1. We need to find how big the offset was (in percent)
    2. 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.

      Cursor
      ?
  2. 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?
    1. 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()
  3. Let's calculate the target position in order to stop the animation at the beginning of the previous, next, or current slide. (calculatedOffset)
    1. There are 3 possible options here, snap to the current, next, or previous slide.
    2. If offset is in margin range we want to return the current position (which should be a rounded value)
    3. 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())
    4. e
    5. 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.
  4. 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.
    1. Define CAROUSEL_START_POINT.
    2. GAP_ON_END is a gap that we want to get rid of.
    3. CAROUSEL_END_POINT is the distance at which the last slide will end
    4. Implement logic to decide if we should stick to the CAROUSEL_START_POINT, END_POINT or let the offset be the input offset
  5. 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:

  1. #detectIfCarouselIsOnEnds() - Ends are green points on the illustration below. We find end point using #getCarouselEnd()
  2. 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.
  3. Then we need to #getHowManySlidesFills() that white space and save it in slideAmount variable.
  4. 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

Vincent opening a mystery briefcase.
What if the briefcase contains holy grail of javascript sliders? Pulp Fiction

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.

Vincent says 'oh we happy'

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 😉

Your contribution means a lot!

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 ✌!