Build a Full Featured Tinder Like Carousel in Vanilla JavaScript
It’s undeniable that the Tinder swipe carousel is really captivating. Let’s build one in pure JavaScript!

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.

Build a Full Featured Tinder Like Carousel in Vanilla JavaScript

#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.

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 on card, top (1) or bottom (-1)
    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 the axes
  let propX = e.deltaX / this.board.clientWidth
  let propY = e.deltaY / this.board.clientHeight

  // 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'

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

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 — like & share!

You can find the full code on my GitHub repository:

https://github.com/simonepm/likecarousel