Implementing a Star Rating component in Vanilla JS

Star Rating/Review UI is a common sighting across different sites on the Internet.

Today, we will implement a simple star rating component using Vanilla JS.

We are going to use Revealing-module-pattern here and our main module API would look like so :-

const ratingModule = makeStarRating();

ratingModule will expose two methods in the name of getStarComponent and getRating.

But before we go into the technical nitty-gritties of JS here, let's try to visualize how the HTML will look for this :-

<ul class="stcomp">
    <li data-rating="1" class="star" ></li>
    <li data-rating="2" class="star" ></li>
    <li data-rating="3" class="star" ></li>
    <li data-rating="4" class="star" ></li>
    <li data-rating="5" class="star" ></li>
</ul>

We will not actually use HTML to make this but the DOM APIs. Still it's good to pre-visualize how we are going to identify the rating value from each element and that is via the data-rating custom attribute which will be available to us as dataset.rating when using DOM APIs. Also CSS isn't the focus of this article. Though, it will available in the final codepen implementation.

So let's start by making a basic skeleton in JS for now :-

const makeStarRating = function (noOfStars = 5) {
  let rating = 0;
  let starComponent;

  function changeRating(newRating) {
    rating = newRating;
  }

  function getStarComponent() {
    if (!starComponent) {
     // create Star Component
    }
    return starComponent;
  }

  function renderChanges(rating) {
  // render UI changes as per rating passed
  }

  function getRating() {
    return rating;
  }

 function onMouseClick(){
  // click event handler
  }

function onMouseOver(){
// mouseover event handler
}

function onMouseLeave(){
// mouseleave event handler
}

  return { getRating, getStarComponent };
};

That's a skeleton alright !!!

So from the above you can see that we also have provided noOfStars (with default value of 5) as argument to makeStarRating which will be used by renderChanges(rating) later on.

So we have to first create a star component and return it if it's not already present. Here is how we can do it by implementing getStarComponent() :-

function getStarComponent() {
    if (!starComponent) {
      starComponent = document.createElement("ul");
      starComponent.className = "stcomp";
      for (let i = 0; i < noOfStars; i++) {
        const li = document.createElement("li");
        li.setAttribute("data-rating", i + 1);
        li.className = "star";
        starComponent.append(li);
      }
      starComponent.addEventListener("mouseover", onMouseOver);
      starComponent.addEventListener("mouseleave", onMouseLeave);
      starComponent.addEventListener("click", onMouseClick);
    }
    return starComponent;
  }

Here we are basically creating an ul element and appending to it li, noOfStars times. And setting the data-rating attribute and className property of each li element. Finally adding the relevant code for registering event handlers. One important thing to notice is that we are making use of event delegation so that only our parent ul has one event handler (for each relevant event) which can take care of events bubbling from child li elements. The event bubbling is only beneficial for click and mouseover events. For mouseleave event we don't need it since we only want the rating to get reflected once we leave the parent ul container. And fun fact, mouseleave doesn't bubble !!

Now let's see how renderChanges(rating) will look like :-

function renderChanges(rating) {
    for (let index = 0; index < rating; index++) {
      starComponent.children[index].classList.add("star-filled");
    }
    for (let index = rating; index < noOfStars; index++) {
      starComponent.children[index].classList.remove("star-filled");
    }
  }

The above is actually going to reflect our UI changes for the stars. We will have a class by the name of star-filled to highlight a star.

Up to the rating number, all the stars would be highlighted and after that all the stars will remain non-highlighted.

Now comes the part where our event handlers come into picture, the first one being, onMouseClick :-

function onMouseClick(e) {
    let star = e.target;
    let isStar = star.classList.contains("star");
    if (isStar) { 
      let { rating } = star.dataset;
      rating = rating === getRating() ? 0 : rating;
      changeRating(rating);
      renderChanges(rating);
    }
  }

Above we first check whether the target which is clicked is a star or not. If it is, we get the rating from the dataset property. Now we compare it with existing rating (via getRating()) and if both are equal, reset the rating to 0. Then we save this rating and render the changes.

We also want a hoverable star highlight feature for our component. We can achieve that via the combination of mouseover and mouseleave like so :-

function onMouseOver(e) {
    let isStar = e.target.classList.contains("star");
    if (isStar) {
      const { rating } = e.target.dataset;
      renderChanges(rating);
    }
  }

  function onMouseLeave(e) {
    renderChanges(rating);
  }

Here inside onMouseOver , we just skip the check for rating and saving rating bit which we are earlier doing using changeRating(rating) inside onMouseClick. We only want to reflect these changes in the UI but not persist unless click action is performed.

And on mouseleave, just render the changes with the current saved rating (Bless you closures!!!).

And that's it for a simple implementation !!

We can use makeStarRating each time to give us new modules and each of such modules can call their getStarComponent to return the parent ul which can be appended to other containers.
Below is a working implementation of the same with keyboard focusing capabilities as well. I didn't cover it since that could be an overkill for a simple implementation but can surely be looked into. Roving tabindex is the technique which I have used which you can learn from here.

I am open to any feedback you have regarding the writeup or implementation. That's how I learn :)

21