WebGL enhanced drag slider tutorial with curtains.js (part 1)
Overview
Please note that this tutorial was originally posted on Medium here.
In this tutorial, we will learn how to build a drag slider in javascript, and enhance it with powerful WebGL capabilities.
The slider will be written in 3 parts. In the first part, we’re going to write a Slider class that will create a neat responsive drag slider. In the second part we’re going to extend it to add the WebGL effect. In the third and last part, we'll see how to improve the overall performance by removing all unnecessary reflow / layout calls.
The WebGL part will be handled by curtains.js, an open source vanilla javascript library. It will be particularly useful here as its main purpose is to enhance DOM elements with WebGL effects. With few lines of javascript, you will be able to create WebGL textured planes bound to our slider items HTML elements and then post-process the whole scene.
We can then easily see the advantages of using curtains.js:
Clean & SEO friendly HTML code.
You don’t have to worry about your WebGL objects sizes and positions as most of the stuff (like resize) will be handled under the hood by the library.
If in any case there’s an error during the WebGL initialization or in your shaders, the slider will still work!
Anyway we’ll see that in part two, for now we’ll just take care of the javascript drag slider. This is what we’ll have at the end of the first part:
Part 1. HTML & CSS
HTML
HTML is really basic. We’re just going to add a slider.steup.js javascript file just before the body closing tag, this is where you’ll put your javascript code.
<body>
<div id="content">
<h1 id="title">My awesome slider</h1>
<!-- drag slider -->
<div id="planes">
<div class="plane-wrapper">
<span class="plane-title">Slide 1</span>
<div class="plane">
<img src="images/slide-1.jpg" alt="First slide" />
</div>
</div>
<div class="plane-wrapper">
<span class="plane-title">Slide 2</span>
<div class="plane">
<img src="images/slide-2.jpg" alt="Second slide" />
</div>
</div>
<div class="plane-wrapper">
<span class="plane-title">Slide 3</span>
<div class="plane">
<img src="images/slide-3.jpg" alt="Third slide" />
</div>
</div>
<!-- add more slides if you want... -->
</div>
<div id="drag-tip">
Drag to explore
</div>
</div>
<script src="js/slider.setup.js" type="text/javascript"></script>
</body>
CSS
The CSS is also really easy. Our #planes div will use flexbox to display its children. In landscape mode we will set its width based on the width of the slides and their number. On portrait mode, we will reset its width and change the flex direction to column. We will add a few CSS properties during drag: preventing text selection and some animations.
@media screen {
html, body {
min-height: 100%;
}
body {
margin: 0;
font-size: 18px;
font-family: 'Arvo', Verdana, sans-serif;
background: #ece5d1;
line-height: 1.4;
overflow: hidden;
}
/*** content ***/
#content {
position: relative;
z-index: 2;
overflow: hidden;
}
#title {
position: fixed;
top: 20px;
right: 20px;
left: 20px;
z-index: 1;
pointer-events: none;
font-size: 1.5em;
line-height: 1;
margin: 0;
text-transform: uppercase;
color: #032f4d;
text-align: center;
}
#planes {
/* width of items * number of items */
width: calc(((100vw / 1.75) + 10vw) * 7);
padding: 0 2.5vw;
height: 100vh;
display: flex;
align-items: center;
cursor: move;
transition: background-color 0.5s;
}
#planes.dragged {
background-color: #03879a;
}
.plane-wrapper {
position: relative;
width: calc(100vw / 1.75);
height: 70vh;
margin: auto 5vw;
text-align: center;
}
/* disable pointer events and text selection during drag */
#planes.dragged .plane-wrapper {
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.plane-title {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
transform: translate3D(-50%, -50%, 0);
font-size: 4vw;
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
color: #032f4d;
text-stroke: 1px white;
-webkit-text-stroke: 1px white;
transition: color 0.5s;
}
#planes.dragged .plane-title {
color: transparent;
}
.plane {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
overflow: hidden;
transition: filter 0.5s;
}
#planes.dragged .plane {
filter: grayscale(1);
}
.plane img {
display: block;
min-width: 100%;
min-height: 100%;
object-fit: cover;
/* prevent original image from dragging */
pointer-events: none;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
}
#drag-tip {
position: fixed;
right: 20px;
bottom: 20px;
left: 20px;
z-index: 1;
pointer-events: none;
font-size: 0.9em;
text-transform: uppercase;
color: #032f4d;
text-align: center;
}
}
@media screen and (orientation: portrait) {
#content {
max-height: 100vh;
}
#planes {
overflow: hidden;
width: 100vw;
padding: 2.5vh 0;
height: auto;
flex-direction: column;
}
.plane-wrapper {
width: 70vw;
height: calc(100vh / 1.75);
margin: 5vw 0;
}
.plane-title {
font-size: 10vw;
}
}
Part 2. The drag slider javascript
Ok, let’s build our slider.
All in all, there’s 3 main steps in this slider:
On mouse down, we’ll start dragging our slider and get our drag start position. The slider starts translating.
On mouse move, if the button is still down, we will update our slider current position by adding our mouse last drag end position and our mouse position minus our mouse drag start position.
On mouse up we’ll get our mouse drag end position to use for next drag. Note that the slider is still translating until our linear interpolation is complete.
We will continuously interpolate the position of our slider between its current translation and our mouse drag movement position. We’ll set up a request animation frame loop to handle that. We’ll start by creating a Slider class that will handle our slider and init its variables. We’ll write three little helper functions that we’ll use to interpolate values, retrieve mouse/touch positions and set the slider direction and boundaries. We’ll also add a few hooks that will be useful later, when we’ll extend the Slider class to add the WebGL part:
class Slider {
/*** CONSTRUCTOR ***/
constructor(options = {}) {
// our options
this.options = {
// slider state and values
// the div we are going to translate
element: options.element || document.getElementById("planes"),
// easing value, the lower the smoother
easing: options.easing || 0.1,
// translation speed
// 1: will follow the mouse
// 2: will go twice as fast as the mouse, etc
dragSpeed: options.dragSpeed || 1,
// duration of the in animation
duration: options.duration || 750,
};
// if we are currently dragging
this.isMouseDown = false;
// if the slider is currently translating
this.isTranslating = false;
// current position
this.currentPosition = 0;
// drag start position
this.startPosition = 0;
// drag end position
this.endPosition = 0;
// slider translation
this.translation = 0;
this.animationFrame = null;
// we'll need to set up our slider here...
}
/*** HELPERS ***/
// lerp function used for easing
lerp(value1, value2, amount) {
amount = amount < 0 ? 0 : amount;
amount = amount > 1 ? 1 : amount;
return (1 - amount) * value1 + amount * value2;
}
// return our mouse or touch position
getMousePosition(e) {
var mousePosition;
if(e.targetTouches) {
if(e.targetTouches[0]) {
mousePosition = [e.targetTouches[0].clientX, e.targetTouches[0].clientY];
}
else if(e.changedTouches[0]) {
// handling touch end event
mousePosition = [e.changedTouches[0].clientX, e.changedTouches[0].clientY];
}
else {
// fallback
mousePosition = [e.clientX, e.clientY];
}
}
else {
mousePosition = [e.clientX, e.clientY];
}
return mousePosition;
}
// set the slider boundaries
// we will translate it horizontally in landscape mode
// vertically in portrait mode
setBoundaries() {
if(window.innerWidth >= window.innerHeight) {
// landscape
this.boundaries = {
max: -1 * this.options.element.clientWidth + window.innerWidth,
min: 0,
sliderSize: this.options.element.clientWidth,
referentSize: window.innerWidth,
};
// set our slider direction
this.direction = 0;
}
else {
// portrait
this.boundaries = {
max: -1 * this.options.element.clientHeight + window.innerHeight,
min: 0,
sliderSize: this.options.element.clientHeight,
referentSize: window.innerHeight,
};
// set our slider direction
this.direction = 1;
}
}
/*** HOOKS ***/
// this is called once our mousedown / touchstart event occurs and the drag starts
onDragStarted(mousePosition) {
// we'll use it later
// note the mouse/touch position parameter
}
// this is called while we are currently dragging the slider
onDrag(mousePosition) {
// we'll use it later
// note the mouse/touch position parameter
}
// this is called once our mouseup / touchend event occurs and the drag ends
onDragEnded(mousePosition) {
// we'll use it later
// note the mouse/touch position parameter
}
// this is called continuously while the slider is translating
onTranslation() {
// we'll use it later
}
// this is called once the translation has ended
onTranslationEnded() {
// we'll use it later
}
// this is called after our slider has been resized
onSliderResized() {
// we'll use it later
}
};
We are now going to take care of the animation and translation part. We’ll use a request animation frame loop to update the slider translation at each tick and set up the according hooks.
class Slider {
/*** CONSTRUCTOR ***/
...
/*** HELPERS ***/
...
/*** HOOKS ***/
...
/*** ANIMATIONS ***/
// this will translate our slider HTML element and set up our hooks
translateSlider(translation) {
translation = Math.floor(translation * 100) / 100;
// should we translate it horizontally or vertically?
var direction = this.direction === 0 ? "translateX" : "translateY";
// apply translation
this.options.element.style.transform = direction + "(" + translation + "px)";
// if the slider translation is different than the translation to apply
// that means the slider is still translating
if(this.translation !== translation) {
// hook function to execute while we are translating
this.onTranslation();
}
else if(this.isTranslating && !this.isMouseDown) {
// if those conditions are met, that means the slider is no longer translating
this.isTranslating = false;
// hook function to execute after translation has ended
this.onTranslationEnded();
}
// finally set our translation
this.translation = translation;
}
// this is our request animation frame loop where we will translate our slider
animate() {
// interpolate values
var translation = this.lerp(this.translation, this.currentPosition, this.options.easing);
// apply our translation
this.translateSlider(translation);
this.animationFrame = requestAnimationFrame(this.animate.bind(this));
}
};
In the next part we’re going to write the methods that we’ll bind to the event listeners. This is where we’re going to write our 3 main steps we’ve seen before. We’ll also add a method to be called on resize.
class Slider {
/*** CONSTRUCTOR ***/
...
/*** HELPERS ***/
...
/*** HOOKS ***/
...
/*** ANIMATIONS ***/
...
/*** EVENTS ***/
// on mouse down or touch start
onMouseDown(e) {
// start dragging
this.isMouseDown = true;
// apply specific styles
this.options.element.classList.add("dragged");
// get our touch/mouse start position
var mousePosition = this.getMousePosition(e);
// use our slider direction to determine if we need X or Y value
this.startPosition = mousePosition[this.direction];
// drag start hook
this.onDragStarted(mousePosition);
}
// on mouse or touch move
onMouseMove(e) {
// if we are not dragging, we don't do nothing
if(!this.isMouseDown) return;
// get our touch/mouse position
var mousePosition = this.getMousePosition(e);
// get our current position
this.currentPosition = this.endPosition + ((mousePosition[this.direction] - this.startPosition) * this.options.dragSpeed);
// if we're not hitting the boundaries
if(this.currentPosition > this.boundaries.min && this.currentPosition < this.boundaries.max) {
// if we moved that means we have started translating the slider
this.isTranslating = true;
}
else {
// clamp our current position with boundaries
this.currentPosition = Math.min(this.currentPosition, this.boundaries.min);
this.currentPosition = Math.max(this.currentPosition, this.boundaries.max);
}
// drag hook
this.onDrag(mousePosition);
}
// on mouse up or touchend
onMouseUp(e) {
// we have finished dragging
this.isMouseDown = false;
// remove specific styles
this.options.element.classList.remove("dragged");
// update our end position
this.endPosition = this.currentPosition;
// send our mouse/touch position to our hook
var mousePosition = this.getMousePosition(e);
// drag ended hook
this.onDragEnded(mousePosition);
}
// on resize we will need to apply old translation value to new sizes
onResize(e) {
// get our old translation ratio
var ratio = this.translation / this.boundaries.sliderSize;
// reset boundaries and properties bound to window size
this.setBoundaries();
// reset all translations
this.options.element.style.transform = "tanslate3d(0, 0, 0)";
// calculate our new translation based on the old translation ratio
var newTranslation = ratio * this.boundaries.sliderSize;
// clamp translation to the new boundaries
newTranslation = Math.min(newTranslation, this.boundaries.min);
newTranslation = Math.max(newTranslation, this.boundaries.max);
// apply our new translation
this.translateSlider(newTranslation);
// reset current and end positions
this.currentPosition = newTranslation;
this.endPosition = newTranslation;
// call our resize hook
this.onSliderResized();
}
};
Alright, we’re almost done with our drag slider. We just need to create a setup function that will register all our event listeners and start the animation loop. We’ll call it inside our constructor. We’ll also be creating a function to cleanly destroy our slider as well, which means removing all event listeners and cancel our request animation frame:
class Slider {
/*** CONSTRUCTOR ***/
constructor(options = {}) {
// our options
this.options = {
// slider state and values
// the div we are going to translate
element: options.element || document.getElementById("planes"),
// easing value, the lower the smoother
easing: options.easing || 0.1,
// translation speed
// 1: will follow the mouse
// 2: will go twice as fast as the mouse, etc
dragSpeed: options.dragSpeed || 1,
// duration of the in animation
duration: options.duration || 750,
};
// if we are currently dragging
this.isMouseDown = false;
// if the slider is currently translating
this.isTranslating = false;
// current position
this.currentPosition = 0;
// drag start position
this.startPosition = 0;
// drag end position
this.endPosition = 0;
// slider translation
this.translation = 0;
this.animationFrame = null;
// set up the slider
this.setupSlider();
}
/*** HELPERS ***/
...
/*** HOOKS ***/
...
/*** ANIMATIONS ***/
...
/*** EVENTS ***/
...
/*** SET UP AND DESTROY ***/
// set up our slider
// init its boundaries, add event listeners and start raf loop
setupSlider() {
this.setBoundaries();
// event listeners
// mouse events
window.addEventListener("mousemove", this.onMouseMove.bind(this), {
passive: true,
});
window.addEventListener("mousedown", this.onMouseDown.bind(this));
window.addEventListener("mouseup", this.onMouseUp.bind(this));
// touch events
window.addEventListener("touchmove", this.onMouseMove.bind(this), {
passive: true,
});
window.addEventListener("touchstart", this.onMouseDown.bind(this), {
passive: true,
});
window.addEventListener("touchend", this.onMouseUp.bind(this));
// resize event
window.addEventListener("resize", this.onResize.bind(this));
// launch our request animation frame loop
this.animate();
}
// will be called silently to cleanly remove the slider
destroySlider() {
// remove event listeners
// mouse events
window.removeEventListener("mousemove", this.onMouseMove, {
passive: true,
});
window.removeEventListener("mousedown", this.onMouseDown);
window.removeEventListener("mouseup", this.onMouseUp);
// touch events
window.removeEventListener("touchmove", this.onMouseMove, {
passive: true,
});
window.removeEventListener("touchstart", this.onMouseDown, {
passive: true,
});
window.removeEventListener("touchend", this.onMouseUp);
// resize event
window.removeEventListener("resize", this.onResize);
// cancel request animation frame
cancelAnimationFrame(this.animationFrame);
}
// call this method publicly to destroy our slider
destroy() {
// destroy everything related to the slider
this.destroySlider();
}
};
// custom options
var options = {
easing: 0.075,
duration: 500,
dragSpeed: 1.75,
}
// let's go!
var slider = new Slider(options);
And that’s it for now. I hope you liked it so far.
In the next part we’ll be modifying a bit our HTML & CSS and then add the WebGL part by extending our Slider class.