DEV Community

Lee O'Connell
Lee O'Connell

Posted on

Web components with vanilla JavaScript

What is a web component you ask?

Web components are reusable and encapsulated HTML elements, created using JavaScript. They let you create functionality inside a page that can be reused on another page, project or site. I thought I would create a quick tutorial creating a simple component while explaining the concepts.

Why use web components?

  • Web Components can be used in any framework, meaning if we build a component for one project we can carry it across to another project using a different framework with no extra coding required.
  • Shadow DOM, Shadow DOM allows components to have their own DOM tree that can’t be accidentally accessed from the main document. Styles cannot penetrate a component from the outside, and styles inside a component won’t bleed out.

Creating a simple tooltip component

To explain the basics of components we will create a tooltip component.

To start we will need to create a project to hold our component

.
+-- index.html
+-- components
|   +-- tooltip.js
Enter fullscreen mode Exit fullscreen mode

After we have a basic structure we can start with our index.html file. Lets add some boiler plate html, and import our tooltip.js script

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Web Components</title>
    <!-- scripts -->
    <script src="./components/tooltip.js"></script>
  </head>
  <body>
    <h1>Tooltip example</h1>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Basic setup

Now we have the index.html setup, we can start creating our first web component
Inside of the tooltip.js file we will create a new class called tooltip. We will extend this class from HTMLElement which represents all HTML elements.

class Tooltip extends HTMLElement {}
Enter fullscreen mode Exit fullscreen mode

We have a empty class, now to add the constructor.

constructor() {
    super();
    this.attachShadow({ mode: "open" });
}
Enter fullscreen mode Exit fullscreen mode

Because we are extending the HTMLElement we need to call super() to inherit the features of that class. this.attachShadow({mode: "open"}) attaches our element to the shadow DOM which keeps our components behaviour separate to the rest of the html.

Now we can add some html to our component

this.shadowRoot.innerHTML = `
            <style>
                #tooltip-container {
                    font-size: 24px;
                }

                .tooltip{
                    padding: 1rem;
                    border-radius: 10px;
                    background-color: black;
                    color: white;
                }
            </style>
            <slot></slot>
            <span id="tooltip-container">👉</span>
        `;
Enter fullscreen mode Exit fullscreen mode

So we are accessing the shadowRoots innerHTML and setting it to what we want the component to be. This is mostly normal HTML apart from one element you may not have seen before, the <slot> tag. This is an element that we can use to add elements from the light DOM to inside of our component. In this case we will have the text that our tooltip will wrap around.

We can now define our component for our index.html to use.

customElements.define("example-tooltip", Tooltip);
Enter fullscreen mode Exit fullscreen mode

^ Add this below the class definition. This is how we define our component to use it in our html. It takes two arguments, first the name of the element, this always has to be at least two words separated by a -. The second is our components class.

Let's try it out! In our html let's add our new element.

<example-tooltip>
    Testing the tooltip
</example-tooltip>
Enter fullscreen mode Exit fullscreen mode

We should be able to see our text plus the pointing hand emoji, if we inspect the element we can also see the shadowDOM with our span inside.


Lets add some functionality

This is cool and all, but it doesn't do much... to add some functionality we can add in a method called connectedCallback() this method is called when our component is attached to the DOM.

connectedCallback() {
    console.log("Ready to go 😎");
  }
Enter fullscreen mode Exit fullscreen mode

Try it out now, you can see in the console our component is ready to go. Still a bit useless though.

Let's add some event listeners to see when a mouse hovers over the tooltip.

this._tooltipContainer = this.shadowRoot.querySelector(
      "#tooltip-container"
    );

// Add event listeners to our div element
this._tooltipContainer.addEventListener(
   "mouseover",
   console.log('mouse over');
);
this._tooltipContainer.addEventListener(
    "mouseleave",
    console.log('mouse left');
);
Enter fullscreen mode Exit fullscreen mode

We can listen to the mouse events now, time to add some methods to show a tooltip.

The show method will append a new element into the shadow root with our tooltip text

_showTooltip() {
    this._tooltip = document.createElement("span");
    this._tooltip.innerText = 'Default text';
    this._tooltip.className = "tooltip";

    this.shadowRoot.append(this._tooltip);
}
Enter fullscreen mode Exit fullscreen mode

In this method we are creating a span and setting the text to default text and appending this element to our tooltip element.

Lets handle the mouse leaving the tooltip

_hideTooltip() {
    this._tooltip.remove();
}
Enter fullscreen mode Exit fullscreen mode

Let's update the event listers to call our shiny new methods.
Inside of the connected callback update the event listeners.

this._tooltipContainer.addEventListener(
      "mouseover",
      this._showTooltip.bind(this)
);
this._tooltipContainer.addEventListener(
      "mouseleave",
      this._hideTooltip.bind(this)
);
Enter fullscreen mode Exit fullscreen mode

The .bind(this) is a quirk of Js, if we didn't have it our method wouldn't understand what this is in terms of our class.

We should now have a basic but working tooltip. Try it out!


Adding attributes

A tooltip isn't much use if we cant set the text inside of it. To do this we will use an attribute that we can pass in the tag of the element.
Inside of the connectedCallback() method add:

this._tooltipText = this.getAttribute("tip-text") || "Default Text";
Enter fullscreen mode Exit fullscreen mode

We have the attribute data, now time to use it. Inside of the _showTooltip() update the inner text to:

this._tooltip.innerText = this._tooltipText;
Enter fullscreen mode Exit fullscreen mode

We can now update our html to include the attribute inside of the <example-tooltip> element. Like so:

<example-tooltip tip-text="Some hint about this">
Enter fullscreen mode Exit fullscreen mode

Try it out! We now have a working tooltip, with text we can pass into the component. We can use this component in any html file as long as our script has been imported.


Good practice

We have a working component. But what if our page dynamically adds and removes the element from the DOM. Our event listeners will remain, which could slow things down... There is a method that is called when our element is removed from the dom, the disconnectedCallback() it is similar to the connectedCallback(). To clean up our component we will add the following inside the class:

disconnectedCallback() {
    this._tooltipContainer.removeEventListener("mouseover", this._showTooltip);
    this._tooltipContainer.removeEventListener("mouseleave", this._hideTooltip);
    console.log("All clean 😊");
}
Enter fullscreen mode Exit fullscreen mode

Try selecting the element and deleting it using the dev tools. You should see a console log when the component has been removed.


Thanks for reading!

Thanks for taking the time to read this article, it's been lengthy but hopefully now you understand what a web-component is, how to make one and how they can be beneficial. If you are stuck check out my Github repo for some help. If you are interested in doing more with this concept you should check out Stencil.js, a powerful complier that creates custom elements using typescript.

I am still new to components myself, so any comments are appreciated

Top comments (7)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Since you explicitly asked for comments.

It is your component and content.
It is unlikely there are going to be Event Listeners added by "the outside"
That means you don't need addEventListener (can attach multiple listeners)

this._tooltipContainer.addEventListener(
  "mouseover",
  this._showTooltip.bind(this)
);
Enter fullscreen mode Exit fullscreen mode

can be replaced with an inline Event listener: (overwrites any previously declared listener)

this._tooltipContainer.onmouseover = (evt) => this._showTooltip(evt);
Enter fullscreen mode Exit fullscreen mode

And a function call instead of a function reference; so you don't need oldskool bind

Since these Event listeners are on DOM elements created by your component, it is also unlikely "the outside" is going to reference these DOM elements.
That means any attached Event Listeners will be removed by the GC (Garbage Collection) proces; and there is no need for removeEventListener.

You usualy only need removeEventListener for listeners you attached on DOM elements outside your component. (eg: document.addEventListener)


Also.. you want to write small re-usable components;

SInce you use powerful shadowDOM, there is only one <span>

Rewrite:

this._tooltipContainer = this.shadowRoot.querySelector(
      "#tooltip-container"
);
Enter fullscreen mode Exit fullscreen mode

Remove the id, and write:

this._tooltipContainer = this.shadowRoot.querySelector("span");
Enter fullscreen mode Exit fullscreen mode
Collapse
 
leeoc profile image
Lee O'Connell

Thanks for your comments, I will definitely be using inline event listeners in the future! Still quite new to JavaScript and have a long way to go

Collapse
 
dannyengelman profile image
Danny Engelman

One more thing that helps: inline Event listener is what you create in HTML:

<span onmouseover="functioncall"></span>

Thus span.onmouseover = (evt) => functioncall ...

overwrites (or sets!) the HTML declared Event

Collapse
 
dannyengelman profile image
Danny Engelman

I was too soon with my <span> remark; missed that you added a second span; can't you work that in you base HTML somehow?

Thread Thread
 
dannyengelman profile image
Danny Engelman • Edited

That double span didn't leave my mind today..

I would write it like this: jsfiddle.net/CustomElementsExample...

<my-tooltip tip-text="World!">Hello</my-tooltip>
<script>
  customElements.define("my-tooltip", class Tooltip extends HTMLElement {
    constructor() {
      super()
        .attachShadow({mode: "open"})
        .innerHTML = /*html*/ `
            <style>
             :host{ font-size: 24px; display: inline-block }
             span:not(:empty) {
                   padding: 1rem;
                   border-radius: 10px;
                   background-color: black;
                   color: white;
               }
            </style>
            <slot></slot> 👉 <span></span>`;
      const tooltip = this.shadowRoot.querySelector("span");
      this.onmouseover = (evt) => {
        tooltip.innerHTML = this.getAttribute("tip-text") || "Default Text";
      }
      this.onmouseleave = (evt) => {
        tooltip.innerHTML = "";
      }
    }
  })
</script>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
leeoc profile image
Lee O'Connell

Love this! Never thought a web component could be so clean. Haven’t seen the :not() css operator before, thanks for your comments, definitely have picked up a few new ideas and things to learn 😊

Collapse
 
juanfrank77 profile image
Juan F Gonzalez

Super interesting stuff, thanks for sharing!