Blog

Victor is a full stack software engineer who loves travelling and building things. Most recently created Ewolo, a cross-platform workout logger.

Creating a reader interaction widget

I recently came across a really neat animated interactive component (kudos) on a blog that allowed the reader to "like" the post. I wanted to see if I could implement something similar that would work for mobile as well.

UX

First, the UX - I decided to keep this dead simple with an icon and a "likes" count (like the original).

<div class="likes" data-id="unique-id">
  <span class="fa-stack fa-lg">
    <i class="fa fa-circle-thin fa-stack-2x"></i>
    <i class="fa fa-heart fa-stack-1x"></i>
  </span>
  <span class="likes-text">234 likes</span>
</div>

Instead of the original hover effect, we will go for a click because a tap is what works best on the mobile. Other interaction effects such as a drag or long-press could be considered as well. To keep things simple to start, we will rotate the icon 360° on click.

jQuery plugin

jQuery provides a very nice animate method that can animate our widget. We will create a simple plugin for our component that attaches a click event to the div and spins the icon around for 1 second. We use the borderSpacing property to update the rotation from 0 to 360 in the step function. The styles are reset at the end to allow clicking again but this is optional. Initialize the widget via: $(".likes").likes();.

(function($) {

  function Likes($likes) {
    this.$likes = $likes;
    this.id = this.$likes.attr('data-id');
    this.num = 0;
    this.$icon = $likes.find('.fa-stack');
    this.$likesText = $likes.find('.likes-text');

    this.$icon.click(this.clicked.bind(this));
    this.$likesText.click(this.clicked.bind(this));
  }

  Likes.prototype.clicked = function() {
    this.animateIcon();
  };

  Likes.prototype.animateIcon = function() {
    var self = this;
    var $element = this.$icon;

    $element.animate({ borderSpacing: 360 }, {
      step: function(now, fx) {
        $element.css('-webkit-transform', 'rotate(' + now + 'deg)');
        $element.css('-moz-transform', 'rotate(' + now + 'deg)');
        $element.css('transform', 'rotate(' + now + 'deg)');
      },
      duration: 1000,
      complete: function() {
        $element.css('border-spacing', 0);
        $element.css('-webkit-transform', 'rotate(0deg)');
        $element.css('-moz-transform', 'rotate(0deg)');
        $element.css('transform', 'rotate(0deg)');
      }
    });
  }

  $.fn.likes = function() {
    this.each(function() {
      new Likes($(this));
    });
    return this;
  };

}(jQuery));
Local storage

We will now save the fact that the user has liked an item in the browser localStorage. A more sophisticated strategy could also be implemented on the server-side if you have unique user ids. Instead of initializing this.num = 0;, we do this.num = this.getItemLiked(); in the constructor.

Likes.prototype.setItemLiked = function() {
  if (!window.localStorage) {
    return;
  }

  var likedItems = JSON.parse(window.localStorage.getItem('li'));
  if (!likedItems) {
    likedItems = {};
  }

  likedItems[this.id] = this.num;
  window.localStorage.setItem('li', JSON.stringify(likedItems));
}

Likes.prototype.getItemLiked = function() {
  if (!window.localStorage) {
    return -1;
  }

  var likedItems = JSON.parse(window.localStorage.getItem('li'));
  if (!likedItems) {
    return 0;
  }
  return likedItems[this.id] ? likedItems[this.id] : 0;
}

Likes.prototype.clicked = function() {
  var num = this.getItemLiked();

  if (num === -1) {
    this.$likesText.text('Oops, no browser local storage avaliable :(');
    return;
  } else if (num === 0) {
    this.num++;
  }

  this.animateIcon();
};
Updating text

So far we have only updated the count internally. We can add methods to update the text and display the count now. We also modify the text with a call to action if the user hasn't already clicked the button. To further enable the call to action we will add the following css: .likes { cursor: pointer; }. The constructor now also has a call to this.updateText(); at the end to set the text on initial page load.

Likes.prototype.clicked = function() {
  var num = this.getItemLiked();

  if (num === -1) {
    this.$likesText.text('Oops, no browser local storage avaliable :(');
    return;
  } else if (num === 0) {
    this.num++;
    this.updateItemLiked();
  } 

  this.setItemLiked();
  this.animateIcon();
};

Likes.prototype.updateItemLiked = function() {
  this.updateText();
}

Likes.prototype.updateText = function() {
  var text = 'No likes yet';
  if (this.num) {
    text = this.num + ' likes';
  }

  if (this.getItemLiked() === 0) {
    text += ', click to add yours!';
  } else {
    text += '!';
  }

  this.$likesText.hide().text(text).fadeIn();
}

Note that we added a method updateItemLiked which does not do much for the moment. This will become useful while integrating with the API next.

API Integration

We now want to save our widget counts on the server. Specifically, we want to fetch the latest count on page load and increment the count on click. To enable this we have the following API:

  • GET /api/likes/:id - returns the count
  • POST /api/likes/:id - increments and returns the new count

Thus, instead of updating the text right away in the constructor, update it after having retrieved data from the server. Also the updateItemLiked function now makes an ajax request and updates the text afterwards.

function Likes($likes) {
  this.$likes = $likes;
  this.id = this.$likes.attr('data-id');
  this.num = this.getItemLiked();
  this.$icon = $likes.find('.fa-stack');
  this.$likesText = $likes.find('.likes-text');

  this.$icon.click(this.clicked.bind(this));
  this.$likesText.click(this.clicked.bind(this));

  return $.ajax({
      url: '/api/likes/' + this.id,
      context: this
    })
    .done(function(data) {
      this.num = data.count;
    })
    .always(function() {
      this.updateText();
    });
}

Likes.prototype.updateItemLiked = function() {
  
  return $.ajax({
      url: '/api/likes/' + this.id,
      context: this,
      type: 'POST',
      data: {}
    })
    .done(function(data) {
      this.num = data.count;
    })
    .always(function() {
      this.updateText();
    });
}
Conclusion

Put it all together and voila, you have a nice little interactive widget that you can plug anywhere on your page. Note that there are a quite a few differences from the original, the most important being that you can click and trigger the animation even if you have already liked the page!

There are still some improvements to make:

  • more intuitive ux, get rid of the need for call to action
  • improved animation
  • better CSS (for e.g. hover the widget on scroll)
  • pure JS implementation (no jquery requirement)

I hope you enjoyed this tutorial and feel free to get in touch if you have any questions or would like to propose any improvements. Most importantly, don't forget to like this article!

PS: I considered publishing this as a plugin but I think that it would be far simpler to just write the 100 or so lines of code and integrate it according to your setup.

HackerNews submission / discussion

Back to the article list.