Rotten Tomatoes 🍅 star rating system ⭐ with Web Components 🎆
Tags: html, css, javascript, tutorial
Published: 2021-07-07 18:54:59

On [Rotten Tomatoes](https://www.rottentomatoes.com/), a film can be rated either "fresh" or "rotten". Here's how to build a star rating component that can show both negative and positive values with different icons — in this case, "fresh" or "rotten" tomatoes.

![rotten-tomatoes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v0mkl177wlmsqwwumghx.gif)
<figcaption>Here's what we'll be building</figcaption> 

---

The API for our [Web Component](https://developer.mozilla.org/en-US/docs/Web/Web_Components) will look like this:

```html
<star-rating min="-5" max="5" value="0"></star-rating>
```

First, we set up a `StarRating` Web Component:

```js
class StarRating extends HTMLElement {
    constructor() {
        super();
    }
}

customElements.define("star-rating", StarRating);
```

Within the constructor, we set up a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) to get and set the `min`, `max`, and `value` attributes, for convenience later:

```js
this.attrs = new Proxy(this, {
    get: (o, k) => Number(o.getAttribute(k)),
    set: (o, k, v) => (o.setAttribute(k, String(v)), true),
});

const { min, max, value } = this.attrs;
```

Then, we set up the shadow DOM. For the codepen demo, we also clone and inject the pen's inline stylesheet within the component's constructor, to allow the styles to take effect in the component's shadow DOM. In production, you'd typically want to link out to an external stylesheet instead.

```js
// in constructor

this.attachShadow({ mode: "open" });

const el = document.createElement("div");
el.classList.add("rating");

this.shadowRoot.append(
    document.querySelector("style").cloneNode(true),
    el
);

el.innerHTML = `<div>
    Rating: <span class="score">${value}</span>
</div>

<div class="stars-container">
    <input type="range" min="${min}" max="${max}" step="1" value="${value}">
    <div class="stars" aria-hidden="true"></div>
</div>`;

this.dom = {
    parent: el,
    stars: el.querySelector(".stars"),
    input: el.querySelector("input"),
    score: el.querySelector(".score"),
    get allStars() {
        return [...el.querySelectorAll(".star")];
    }
};

this.renderStars();

// ...

renderStars() {
    const { attrs, dom } = this;
    const { min, max, value } = attrs;

    const starHtml = '<span class="star"></span>';

    dom.stars.innerHTML = `<div class="neg">${starHtml.repeat(
        Math.abs(min)
    )}</div>
        <div class="zero">${starHtml}</div>
        <div class="pos">${starHtml.repeat(max)}</div>`;
}
```

We use `attributeChangedCallback` and `static observedAttributes` to keep the DOM in sync with changes:

```js
static observedAttributes = ["min", "max", "value"];

attributeChangedCallback(name) {
    const { dom, attrs } = this;
    const { value } = attrs;

    switch (name) {
        case "value":
            dom.input.value = value;
            dom.score.textContent = value;

            break;
        case "min":
        case "max":
            this.renderStars();

            break;
        default:
            break;
    }

    this.renderClasses(value);
}
```

Finally, we attach the various listeners we need:

```js
// in constructor

this.attachListeners();

// ...

attachListeners() {
    const { dom, attrs } = this;

    dom.stars.addEventListener("mouseover", (e) => {
        if (e.target.matches(".star")) {
            const val = this.getValFromStar(e.target);

            this.renderClasses(val);
        }
    });

    dom.stars.addEventListener("mouseleave", (e) => {
        this.renderClasses(attrs.value);
    });

    dom.stars.addEventListener("click", (e) => {
        if (e.target.matches(".star")) {
            const val = String(this.getValFromStar(e.target));

            this.attrs.value = val;

            dom.input.focus();
        }
    });

    dom.input.addEventListener("input", (e) => {
        const val = e.currentTarget.value;

        this.attrs.value = val;
    });

    let negative = false;

    dom.input.addEventListener("keydown", (e) => {
        const { min, max } = attrs;

        if (/^\d$/.test(e.key)) {
            const val = Number(e.key);

            this.attrs.value = negative
                ? Math.max(-val, min)
                : Math.min(val, max);
        }

        negative = e.key === "-";
    });
}
```

Note that the behavior on input is controlled by the [`input type="range"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range), so we get all the benefits of that automatically, including keyboard input. The input element is also exposed to accessibility APIs, while the visible stars (tomatoes) are hidden.

We'll need to add some styling to that to hide the native input element from view, though:

```css
.stars-container {
    position: relative;
}

.stars-container:focus-within {
    outline: 3px solid #4caf50;
}

.rating input {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
    pointer-events: none;
}
```

We use [`:focus-within`](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within) to add styling to the container when the input element is focused, and the `input` element itself is visibly hidden.

To style the stars/tomatoes themselves, we use `::before` pseudo elements and emojis.

As there's no rotten tomato emoji available, we add a [`filter: hue-rotate(75deg);`](https://developer.mozilla.org/en-US/docs/Web/CSS/filter) to change the color to green!

```js
.star {
    cursor: pointer;
}

.zero .star::before {
    content: "🚫";
}

.pos .star::before {
    content: "🍅";
}

.neg .star::before {
    content: "🍅";
    filter: hue-rotate(75deg);
}

.neg .star.below,
.pos .star.above,
.zero .star:not(.exact) {
    opacity: 0.1;
}

.pos .star.below,
.neg .star.above,
.exact {
    opacity: 1;
}
```

Finally, we stack the `.pos`, `.zero`, and `.neg` elements on top of each other for better ergonomics on small screens, using a media query and some [`flex-direction`](https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction) trickery:

```css
.stars {
    display: flex;
    flex-direction: row;
}

.neg,
.zero,
.pos {
    display: flex;
}

@media screen and (max-width: 600px) {
    .stars {
        flex-direction: column-reverse;
    }

    .neg {
        flex-direction: row-reverse;
        justify-content: flex-end;
    }
}
```

Here's the finished project:

{% codepen https://codepen.io/lionel-rowe/pen/abWNqyB  %}

Have fun with it, and don't forget to leave your feedback in the comments!