Multicolored hearts randomly placed on a black background, yellow, red, blue and green

While learning about web components a few months ago, I wanted to create my first (useless) little component as a package on NPM. In this article a little tutorial on how I created my first basic web component with LIT, Typescript, using an easy setup with Vite. I will be sharing how to do the setup, some of the ideas behind it, and some of the gotchas.

Each year I add an article on the 14th of February. Not because I’m a big fan of the commercial day called Valentine’s Day, but rather that I started it off as a laugh and somewhat stuck with it. Now It feels like something I have to do, but it does help me to invent something new each year, and since I was learning a bit more about web components at the end of 2023. I thought it would be cool to create a little web component for this day, giving users a little plug-and-play option for animated hearts on their web page.

Setting up our workspace with Vite

Let’s start by setting up our default workspace, Open up the terminal and navigate to the place where you want to install the web component:

cd ./projects/

Initialize Vite

npm create vite@latest

Install dependencies if needed. In my example, the project is named for-love

  • Next choose Lit as a framework
  • As a variant in this case, choose Typescript
  • Change the directory to the folder, npm install, and npm run dev as described after initializing Vite.

Why choose typescript for a web component

To be completely honest, I added the Typescript mostly out of peer pressure. Typescript is an important thing, especially in big projects, but if you ask my personal opinion, for a simple web component like this, it might be a bit too much.

Adding Lit for the developer experience

The Lit library makes it easier and more efficient to work with web components. It provides a more declarative syntax for defining components. This can make our code more readable and maintainable compared to the imperative style required when working directly with the Web Components API. In this tutorial, we’ll be using this library mostly for the basic features of developer experience, but it has a lot more to offer (I’m still learning about it myself).

Setting up the basics of our for-love web component

For this tutorial, it might be a good idea to make the component grow a bit, almost in the same way it was set up from the beginning, and expand it later on. Not every little detail of the web component will be covered in this article, for example, the “contained” and ”multicolor” options will not be added.

So for this for-love-element, we want a few options:

  • Easily change the amount of hearts with an attribute
  • Randomly place the hearts
  • Change the size and color through CSS custom properties
  • Change the amount the hearts sway from left to right with custom properties
  • Change animation duration, iteration, and easing with custom properties

Let’s start with setting up the basic component by reading the amount of hearts as an option and iterating that amount to show a bunch of hearty divs:

Let’s remove everything inside the src folder created by the Vite install, and let’s create our new file called for-love.ts. Let’s add the following.

import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('for-love')

export class ForLove extends LitElement {
  @property({ type: Number }) amount = 10;

  static styles = css`
    /* we will add our styles here */
  `;

  render() {
    return html`
      <div class="for-love-hearts" part="hearts-container">
        ${Array(this.amount).fill('heart').map(index => {
          return html`<div class="heart" data-item="${index}">Heart</div>`;
        })}
      </div>
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'for-love': ForLove;
  }
}

Inside of our index.html change the <my-element> custom element to our <for-love> element. You should now see “Heart” printed 10 times. Inside our <for-love> element, we can adjust the amount by adding <for-love amount=”40”></for-love>. Go on, give it a try…

So, what we did here: We included html, css and LitElement from Lit. This last one is needed to create our custom element with Lit, while html and css give us the possibility to add markup and styling through that cool template literal system. We added a placeholder for our CSS and returned some HTML when the component loads. Inside this component, we add a loop that iterates over our set amount property.

This @property({ type: Number }) amount = 10; decorator defines a public property named amount with a type annotation of Number. This ensures type safety and lets Lit know how to update the property based on its type. We will be needing this because It might not be the best Idea to allow just about any number of hearts in our web component, so let’s restrict it. The following bit of code will check if the amount is more than 400, If so, it will just return 400.

Let’s remove

 @property({ type: Number }) amount = 10;

And replace it with:

  private _maxAmount = 400; // Set your maximum value here

  @property({ type: Number })
  get amount() {
    return this._amount;
  }

  set amount(value: number | string) {
    const parsedValue = typeof value === 'number' ? value : parseInt(value);

    // Check if the parsed value is a valid number
    if (!isNaN(parsedValue)) {
      // Set the value to the maximum if it exceeds the maximum allowed
      this._amount = Math.min(parsedValue, this._maxAmount);
    } else {
      console.warn('Invalid value for amount property. Please provide a valid number.');
    }

    this.requestUpdate('amount'); // Notify LitElement to update the property
  }

  private _amount = 10;

Explaining the maximum value code

First, we define a private property named _maxAmount and set it to 400. This variable determines the highest allowed value for the amount property. This is our safety net, maybe 400 is too much, I’ll let the reader be the judge of that.

Creating the Property:

Next, we declare a public property called amount. Here, we use the @property decorator and specify the type again as Number. This not only clarifies the expected data type but also helps Lit understand how to update the property efficiently, which is one of those superpowers of the library.

We then create a getter and a setter for the amount property. The getter simply returns the current value stored in the private _amount property, providing read access.

The setter is where the magic happens. It handles setting the amount property, accepting either a number or a string as input. Let’s delve into the steps it takes:

  1. Parsing the Input: The code checks if the input is already a number. If so, it’s used directly. If it’s a string, the code attempts to convert it to a number using parseInt. This ensures we’re working with numerical values.
  2. Checking for Validity: The code uses the isNaN function to verify if the parsed value is a valid number. If it’s not, it means the input wasn’t a proper number and a warning message is logged to the console to alert the user.
  3. Enforcing the Limit: If the parsed value is valid, the code compares it to the _maxAmount. Using the Math.min function, it sets the _amount property to the smaller of these two values. This effectively enforces the maximum limit you defined earlier.
  4. Triggering an Update: After setting the _amount property, the code calls this.requestUpdate('amount'). This tells Lit that the property has changed and prompts the component to update itself with the new value. This ensures your UI reflects the latest accepted input, (aka the amount set in the attribute).

Finally, we set the initial value of the _amount property to 10, our default value.

Creating our hearts animation with CSS and custom properties

Time for us to add some love to our component and style our hearts using CSS. We will be using custom properties to create the ability for options in easily. Let’s go inside of our CSS template literals and add the following:

:host {
  --_color: var(--fl-heart-color, #F8C8DC);
  --_size: var(--fl-heart-size, 3vw);
  --_sway: var(--fl-sway, 5);
  --_iteration: var(--fl-iteration, infinite);
  --_duration: var(--fl-duration, 10s);
  --_ease: var(--fl-ease, ease-in-out);
}

.for-love-hearts {
  position: fixed;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}

If you haven’t heard of :host before, this is a CSS pseudo class that is used to style our custom element itself, this is a great place to store our variables.

Using underscore variants as private variables in these custom properties, we can read if a variable is present or else use a fallback. I like this way of working, it’s pretty clean. Our fixed container is going to fill in the whole window and pointer events will be set to none. This is the container that will contain our hearts.

Now let’s create our hearts with CSS only, I’m not going to go into much detail about this technique as this is a bit off-topic. The important part here is to notice the usage of custom properties, we will be placing these hearts in a random matter across the width of the screen. For that, we will need a new custom property called --left.

.heart {
  position: absolute;
  top: 0;
  margin: auto;
  left: var(--left, 0);
  height: var(--_size);
  width: var(--_size);
  background-color: var(--_color);
  rotate: -45deg;
  pointer-events: none;
}

.heart::after, .heart::before {
  background-color: var(--_color);
  content: "";
  border-radius: 50%;
  position: absolute;
  width: var(--_size);
  height: var(--_size);
  top: 0px;
  left: calc(var(--_size) / 2);
}

.heart::before {
  top: calc(var(--_size) / -2);
  left: 0;
}

Let’s make that new custom property --left work, Let’s update our hearts loop to set that custom property inline of each heart between a range of 0% and 100%:

${Array(this.amount).fill('heart').map(index => {
    const randomNumberLeft = Math.floor(Math.random() * 101);
    return html`<div class="heart" data-item="${index}" style="--left: ${randomNumberLeft}%;"></div>`;
})}

Great! We now have a bunch of hearts sticking at the top of our screen:

All hearts are visible in the browser window, but unortunatly sticking to the top.

All that’s left for us to do is add a bit of animation. However, they are not perfectly optimized yet as I’m still using a margin for the swaying (which doesn’t make use of the GPU). I might update that later on, but for now, this is how I made the animation:

@keyframes goToTopHeart {
  0% {
    opacity: 0;
    translate: 0 100vh;
    scale: .2;
    margin-left: calc(var(--_sway) * 1px);
  }
  10% {
    opacity: 1;
  }
  18% {
    scale: 1;
  }
  10%, 30%, 50%, 70%, 90% {
    margin-left: calc(var(--_sway) * -1px);
  }
  20%, 40%, 60%, 80%, 100% {
    margin-left: calc(var(--_sway) * 1px);
  }
  80% {
    opacity: 1;
    scale: 1;
  }
  100% {
    translate: 0 -100%;
    opacity: 0;
    scale: 0;
  }
}

And let’s now initialize the animation on our hearts, once again making use of those custom properties that we’ve set in our :host:

.heart {
  /* previous code */
  opacity: 0;
  animation: goToTopHeart var(--_duration) var(--_iteration) var(--_ease);
  animation-delay: calc(var(--delay) * .1s);
}

Notice that we’ve set a delay, we will be needing this later on. Because - for now - our hearts look like this:

All that is left for us to do is to add a delay for each heart, let’s do that by overwriting that custom property inside of our loop, the full html will look like this:

 return html`
        <div class="for-love-hearts" part="hearts-container">
          ${Array(this.amount).fill('heart').map(index => {
            const randomNumberLeft = Math.floor(Math.random() * 101);
            const randomNumberDelay = Math.floor(Math.random() * 101);
      return html`<div class="heart" data-item="${index}" style="--left: ${randomNumberLeft}%; --delay: ${randomNumberDelay};"></div>`;
    })}
        </div>
    `;

Consider user preferences

Keep in mind that there are users out there that don’t want to have any unintentional animations on the screen. Yes, it would make this web component a disturbing factor, but better that, than an unhappy user. Let’s disable animation and hide the container for users that set their preferences to reduced motion by adding the following to our CSS:

@media (prefers-reduced-motion) {
  .heart, .for-love-hearts {
    display: none;
    animation: none;
  }
}

All of these things together result in the following:

A lovely little experiment…

If you’d like to take a bit of a deeper dive inside of this web component, here are a few links:

Although I’m absolutely sure that this web component is useless and not production-ready because it could use some optimising, I’m very pleased to have created my first web component. I do think it could solve a lot of the tedious tasks we have as front-end developers and I do believe they deserve a bit more attention in 2024.

 in  html , javascript