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 "Like Carousel" on my own in pure JavaScript and to share my experience with you.

The swipe carousel could 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; this .handle(); } handle() { this .cards = this .board.querySelectorAll( ".card" ); this .topCard = this .cards[ this .cards.length - 1 ]; if ( this .cards.length > 0 ) { this .hammer = new Hammer( this .topCard); this .hammer.add( new Hammer.Pan({ position : Hammer.position_ALL, threshold : 0 }) ); 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 this .topCard.style.transition = null 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 } let posX = e.deltaX + this .startPosX let posY = e.deltaY + this .startPosY this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px)' if (e.isFinal) { this .isPanning = false this .topCard.style.transition = 'transform 200ms ease-out' 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 this .topCard.style.transition = null 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 let bounds = this .topCard.getBoundingClientRect() this .isDraggingFrom = (e.center.y - bounds.top) > this .topCard.clientHeight / 2 ? -1 : 1 } let posX = e.deltaX + this .startPosX let posY = e.deltaY + this .startPosY let propX = e.deltaX / this .board.clientWidth let dirX = e.deltaX < 0 ? -1 : 1 let deg = this .isDraggingFrom * dirX * Math .abs(propX) * 45 this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' if (e.isFinal) { this .isPanning = false this .topCard.style.transition = 'transform 200ms ease-out' 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.

onPan(e) { if (! this .isPanning) { this .isPanning = true this .topCard.style.transition = null 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 let bounds = this .topCard.getBoundingClientRect() this .isDraggingFrom = (e.center.y - bounds.top) > this .topCard.clientHeight / 2 ? -1 : 1 } let posX = e.deltaX + this .startPosX let posY = e.deltaY + this .startPosY let propX = e.deltaX / this .board.clientWidth let propY = e.deltaY / this .board.clientHeight let dirX = e.deltaX < 0 ? -1 : 1 let deg = this .isDraggingFrom * dirX * Math .abs(propX) * 45 this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' if (e.isFinal) { this .isPanning = false this .topCard.style.transition = 'transform 200ms ease-out' if (propX > 0.25 && e.direction == Hammer.DIRECTION_RIGHT) { posX = this .board.clientWidth this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' } else if (propX < -0.25 && e.direction == Hammer.DIRECTION_LEFT) { posX = - ( this .board.clientWidth + this .topCard.clientWidth) this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' } else if (propY < -0.25 && e.direction == Hammer.DIRECTION_UP) { posY = - ( this .board.clientHeight + this .topCard.clientHeight) this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' } else { 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 this .topCard.style.transition = 'transform 200ms ease-out' if (propX > 0.25 && e.direction == Hammer.DIRECTION_RIGHT) { successful = true posX = this .board.clientWidth } else if (propX < -0.25 && e.direction == Hammer.DIRECTION_LEFT) { successful = true posX = - ( this .board.clientWidth + this .topCard.clientWidth) } else if (propY < -0.25 && e.direction == Hammer.DIRECTION_UP) { successful = true posY = - ( this .board.clientHeight + this .topCard.clientHeight) } if (successful) { this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg)' setTimeout( () => { this .board.removeChild( this .topCard) this .push() this .handle() }, 200 ) } else { 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:

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) { let propX = (e.center.x - e.target.getBoundingClientRect().left) / e.target.clientWidth let rotateY = 15 * (propX < 0.05 ? -1 : 1 ) this .topCard.style.transition = 'transform 100ms ease-out' this .topCard.style.transform = 'translateX(-50%) translateY(-50%) rotate(0deg) rotateY(' + rotateY + 'deg) scale(1)' setTimeout( () => { 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.

let deg = this .isDraggingFrom * dirX * Math .abs(propX) * 45 let scale = ( 95 + ( 5 * Math .abs(propX))) / 100 this .topCard.style.transform = 'translateX(' + posX + 'px) translateY(' + posY + 'px) rotate(' + deg + 'deg) rotateY(0deg) scale(1)' 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 — like & share!

You can find the full code on my GitHub repository:

https://github.com/simonepm/likecarousel