Thinking outside of the dating world, the concept of swiping left or right to express a choice could be applied for a lot of use cases with the purpose of collecting user preferences about any kind of topic.
In this context, I decided to build a swipe carousel that catches user likes and dislikes on my own in pure JavaScript and to share my experience with you. Our carousel can be seen as FIFO deck of piled cards. A right swipe on the topmost card means a "like", on the other direction a "dislike". Furthermore, a swipe towards the top border of the screen is considered a "super-like". When the first card is thrown away, the second one takes its place until no more cards are present in the deck.
This is what we are going to achieve:
Nice! Isn’t it?
Step 1 — Let’s do it
As first step, we are going to write a bootstrap page for our project:
<body>
<div id="board">
<div class="card"></div>
</div>
</body>
#board
div is our canvas and every immediate children with class .card
an element of the deck.
Given that browsers index absolute positioned divs by order of appearance, we know that the last .card element will always be the topmost card.
Step 2 — CSS to the rescue!
If we try to open our web page we will only see a white screen. CSS to the rescue!
#board {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background-color: rgb(245, 247, 250);
}
.card {
width: 320px;
height: 320px;
position: absolute;
top: 50%;
left: 50%;
border-radius: 1%;
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.1);
background-color: white;
transform: translateX(-50%) translateY(-50%);
}
The result should be a white card in the middle of a slightly grey background.
Step 3 — It’s a kind of magic
Now we need to capture the swiping event as soon as the user interacts with the card.
In order to do that I chose the amazing HammerJS library.
Hammer is an open-source library that recognize gestures made by touch, mouse and pointerEvents. It doesn’t have any dependencies and it’s small.
Included the Hammer library (v2.0.8 at the time of writing), we can start building the class that will actually handle the gestures:
class Carousel {
constructor(element) {
this.board = element;
// handle gestures
this.handle();
}
handle() {
// list all cards
this.cards = this.board.querySelectorAll(".card");
// get top card
this.topCard = this.cards[this.cards.length - 1];
if (this.cards.length > 0) {
// listen for pan gesture on top card
this.hammer = new Hammer(this.topCard);
this.hammer.add(
new Hammer.Pan({
position: Hammer.position_ALL,
threshold: 0
})
);
// pass event data to custom callback
this.hammer.on("pan", this.onPan);
}
}
onPan(e) {
console.log("panning...");
}
}
let board = document.querySelector('#board')
let carousel = new Carousel(board)
Step 4 — Come on, make a move on me!
Hammer passes a lot of info about detected gestures as argument of the callback functions we pass to its recognizers.
Let’s write a logic that makes the card move along with the pointer when dragged and then bounce back when released.
onPan(e) {
if (!this.isPanning) {
this.isPanning = true
// remove transition property
this.topCard.style.transition = null
// get starting coordinates
let style = window.getComputedStyle(this.topCard)
let mx = style.transform.match(/^matrix\((.+)\)$/)
this.startPosX = mx ? parseFloat(mx[1].split(', ')[4]) : 0
this.startPosY = mx ? parseFloat(mx[1].split(', ')[5]) : 0
}
// calculate new coordinates
let posX = e.deltaX + this.startPosX
let posY = e.deltaY + this.startPosY
// move card
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px)'
if (e.isFinal) {
this.isPanning = false
// set back transition property
this.topCard.style.transition = 'transform 200ms ease-out'
// reset card position
this.topCard.style.transform = 'translateX(-50%) translateY(-50%)'
}
}
When the user starts to drag the card, we store the initial coordinates and remove the CSS transition property in order to obtain a movement at the same speed of the pointer.
Given that we do not know the CSS translateX and translateY units of measure, we call getComputedStyle to translate the existing properties in a CSS transform matrix with the spacial coordinates in pixels.
When the card is released (e.isFinal) we set back the transition properties, so it can go back smoothly over the top of the deck (ease-out).
You can be tempted to change the top and left properties instead of dealing with translateX and translateY properties, but modern browsers make an amazing work under the hood — by making use of the GPU of your device — to display smooth transitions when changes to the CSS transformation properties are applied.
Step 5 – 45 shades of rotation
If you ever used the Tinder app you probably noticed that the more you drag towards the screen vertical borders, the more the card rotates in the chosen direction.
To replicate this feature we need to calculate a value between 0 and +/- 45 degrees based on the ratio between the card position on the X axis and the total width of the view.
onPan(e) {
if (!this.isPanning) {
this.isPanning = true
// remove transition property
this.topCard.style.transition = null
// get card coordinates in pixels
let style = window.getComputedStyle(this.topCard)
let mx = style.transform.match(/^matrix\((.+)\)$/)
this.startPosX = mx ? parseFloat(mx[1].split(', ')[4]) : 0
this.startPosY = mx ? parseFloat(mx[1].split(', ')[5]) : 0
// get card bounds
let bounds = this.topCard.getBoundingClientRect()
// get finger position, top (1) or bottom (-1) of the card
this.isDraggingFrom =
(e.center.y - bounds.top) > this.topCard.clientHeight / 2 ? -1 : 1
}
// get new coordinates
let posX = e.deltaX + this.startPosX
let posY = e.deltaY + this.startPosY
// get ratio between swiped pixels and X axis
let propX = e.deltaX / this.board.clientWidth
// get swipe direction, left (-1) or right (1)
let dirX = e.deltaX < 0 ? -1 : 1
// get degrees of rotation (between 0 and +/- 45)
let deg = this.isDraggingFrom * dirX * Math.abs(propX) * 45
// move and rotate card
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)'
if (e.isFinal) {
this.isPanning = false
// set back transition property
this.topCard.style.transition = 'transform 200ms ease-out'
// reset card position
this.topCard.style.transform = 'translateX(-50%) translateY(-50%) rotate(0deg)'
}
}
Step 6 — I believe I can fly!
When the finger position reach a certain threshold, the card should be thrown away in the chosen direction when released.
In order to do that, we can reuse the calculations made before and apply those numbers to know if we should put the card back on top of the deck or not at the end of the pan gesture.
if (e.isFinal) {
this.isPanning = false
// set back transition property
this.topCard.style.transition = 'transform 200ms ease-out'
// check threshold
if (propX > 0.25 && e.direction == Hammer.DIRECTION_RIGHT) {
// get right border position
posX = this.board.clientWidth
// throw card towards the right border
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)'
} else if (propX < -0.25 && e.direction == Hammer.DIRECTION_LEFT) {
// get left border position
posX = - (this.board.clientWidth + this.topCard.clientWidth)
// throw card towards the left border
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)'
} else if (propY < -0.25 && e.direction == Hammer.DIRECTION_UP) {
// get top border position
posY = - (this.board.clientHeight + this.topCard.clientHeight)
// throw card towards the top border
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)'
} else {
// reset card position
this.topCard.style.transform =
'translateX(-50%) translateY(-50%) rotate(0deg)'
}
}
Step 7— Hit another one!
It’s time to add cards programmatically after every successful swipe (many thanks to picsum.photos for their handy placeholder images API):
push() {
let card = document.createElement('div')
card.classList.add('card')
card.style.backgroundImage = "url('https://picsum.photos/320/320/?random=" + Math.round(Math.random()*1000000) + "')"
this.board.insertBefore(card, this.board.firstChild)
}
Place a second card inside the #board
div:
<body>
<div id="board">
<div class="card"></div>
<div class="card"></div>
</div>
</body>
Now, let’s add a new card after every successful swipe:
if (e.isFinal) {
this.isPanning = false
let successful = false
// set back transition property
this.topCard.style.transition = 'transform 200ms ease-out'
// check threshold and movement direction
if (propX > 0.25 && e.direction == Hammer.DIRECTION_RIGHT) {
successful = true
// get right border position
posX = this.board.clientWidth
} else if (propX < -0.25 && e.direction == Hammer.DIRECTION_LEFT) {
successful = true
// get left border position
posX = - (this.board.clientWidth + this.topCard.clientWidth)
} else if (propY < -0.25 && e.direction == Hammer.DIRECTION_UP) {
successful = true
// get top border position
posY = - (this.board.clientHeight + this.topCard.clientHeight)
}
if (successful) {
// throw card in the chosen direction
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)'
// wait transition end
setTimeout(() => {
// remove swiped card
this.board.removeChild(this.topCard)
// add new card
this.push()
// handle gestures on new top card
this.handle()
}, 200)
} else {
// reset card position
this.topCard.style.transform =
'translateX(-50%) translateY(-50%) rotate(0deg)'
}
}
Given that we are creating a new Hammer instance over and over again after every successful swipe, we should destroy the previous one to improve memory allocation; add the following line inside the handle method, just before calling new Hammer:
// destroy previous Hammer instance, if present
if (this.hammer) this.hammer.destroy();
Step 8 — Visual-Effects
In the original carousel a nice bump effect occurs when the user taps on the topmost card vertical sides and next cards slowly scale up in size as soon as the first one starts to leave the deck.
For the first effect we simply need to add the Hammer Tap event listener and a method for handling it:
onTap(e) {
// get finger position on top card
let propX = (e.center.x - e.target.getBoundingClientRect().left) / e.target.clientWidth
// get rotation degrees around Y axis (+/- 15) based on finger position
let rotateY = 15 * (propX < 0.05 ? -1 : 1)
// enable transform transition
this.topCard.style.transition = 'transform 100ms ease-out'
// apply rotation around Y axis
this.topCard.style.transform =
'translateX(-50%) translateY(-50%) rotate(0deg) rotateY(' + rotateY + 'deg) scale(1)'
// wait for transition end
setTimeout(() => {
// reset transform properties
this.topCard.style.transform =
'translateX(-50%) translateY(-50%) rotate(0deg) rotateY(0deg) scale(1)'
}, 100)
}
For the second, we need to apply the ratio between the card X position and the total width of the view to the CSS scale property as we did for the rotate property while panning.
// get degrees of rotation, between 0 and +/- 45
let deg = this.isDraggingFrom * dirX * Math.abs(propX) * 45
// get scale ratio, between .95 and 1
let scale = (95 + (5 * Math.abs(propX))) / 100
// move and rotate top card
this.topCard.style.transform =
'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg) rotateY(0deg) scale(1)'
// scale up next card
if (this.nextCard) this.nextCard.style.transform =
'translateX(-50%) translateY(-50%) rotate(0deg) rotateY(0deg) scale(' + scale + ')'
Remember to set an initial scale value of 0.95 to all .card divs in our CSS.
.card {
// ...
transform: translateX(-50%) translateY(-50%) scale(0.95);
}
We did it!
Thanks for having followed this tutorial.
If you enjoyed — please — share!
You can find the full code on my GitHub repository: