DEV Community

loading...

Building Web Components with Vanilla JavaScript

aspittel profile image Ali Spittel ・6 min read

Back in 2015, I was in the midst of learning my first front-end framework -- AngularJS. The way I thought of it was that I was building my own HTML tags with custom features. Of course, that wasn't what was really happening, but it helped lower the learning curve.

Now, you can actually build your own HTML tags using web components! They are still an experimental feature -- they work in Chrome and Opera, can be enabled in FireFox, but they are still unimplemented in Safari and Edge. Once they fully roll out, they will be an even more awesome tool for building reusable components in purely vanilla JavaScript -- no library or framework needed!

Learning Process

I had a lot of difficulty finding articles and examples on web components written in Vanilla JS. There are a bunch of examples and articles on Polymer, which is a framework for writing web components that includes polyfills for browsers that don't support web components yet. The framework sounds awesome, and I may try working with it in the future, but I wanted to use just vanilla JavaScript for this particular project.

I ended up mostly using the MDN documentation on the Shadow DOM in order to build my project. I also looked through CodePen and the WebComponents site, though I didn't find too much on either that was similar to what I wanted to build.

I also really liked Joseph Moore's article on web components which came out while I was working on this project! It covered some of the benefits of using web components:they work with all frameworks and they are simple to implement and understand since they just use vanilla JavaScript.

Final Project

On a lot of my projects, I use a similar design scheme for both personal branding and to make it so that I don't have to come up with a new design! In particular, I use a heading where each letter is a different color and has a falling animation on it. My personal site alispit.tel is a pretty good example of this! I also have that text on my resume, conference slides, and I have plans to use it for other sites in the near future as well! The catch with it is that CSS doesn't allow you to target individual characters -- other than the first one. Therefore, each letter has to be wrapped in a span. This can get pretty painful to write, so I decided this was the perfect place to use a webcomponent!

display of the rainbow letters

Since I had difficulty finding articles on people writing web components, I'm going to go pretty in depth with the code here.

First, the HTML code to get the web component to render looks like this:

  <rainbow-text text="hello world" font-size="100"></rainbow-text>

The web component is called rainbow-text and it has two attributes: the text, which will be what the component renders, and the font size. You can also use slots and templates to insert content; however, in my use case, they would have added additional overhead. I wanted to input text and then output a series of HTML elements with the text separated by a character, so the easiest way was to pass in the text via an attribute -- especially with the Shadow DOM.

So, what is the Shadow DOM? It actually isn't new and it isn't specific to web components. The shadow DOM introduces a subtree of DOM elements with its own scope. It also allows us to hide child elements. For example, a video element actually is a collection of HTML elements; however, when we create one and inspect it, we only see the video tag! The coolest part of the shadow DOM, for me, was that the styling was scoped! If I add a style on my document that, for example, modifies all divs, that style won't affect any element inside the shadow DOM. Inversely, styles inside the shadow DOM won't affect elements on the outer document's DOM. This is one of my favorite features of Vue, so I was super excited that I could implement something similar without a framework!

Let's now move on to JavaScript code which implements the custom element. First, you write a JavaScript class that extends the built-in HTMLElement class. I used an ES6 class, but you could also use the older OOP syntax for JavaScript if you wanted. I really enjoy using ES6 classes, especially since I am so used to them from React! The syntax felt familiar and simple.

The first thing that I did was write the connectedCallback lifecycle method. This is called automatically when the element is rendered -- similar to componentDidMount in React. You could also use a constructor similar to any other ES6 class; however, I didn't really have a need for one since I wasn't setting any default values or anything.

Inside the connectedCallback, I first instantiated the shadow DOM for the element by calling this.createShadowRoot(). Now, the rainbow-text element is the root of its own shadow DOM, so it's child elements will be hidden and have their own scope for styling and external JavaScript mutations. Then, I set attributes within the class from the HTML attributes being passed in. Within the class, you can think of this referring to the rainbow-text element. Instead of running document.querySelector('rainbow-text').getAttribute('text'), you can just run this.getAttribute('text') to get the text attribute from the element.

class RainbowText extends HTMLElement {
  connectedCallback () {
    this.createShadowRoot()
    this.text = this.getAttribute('text')
    this.size = this.getAttribute('font-size')
    this.render()
  }

render is a method that I wrote, that is called in the connectedCallback. You can also use the disconnectedCallback and the attributeChangedCallback lifecycle methods if they would be helpful in your code! I just separated it out in order to adhere to Sandi Metz's rules which I adhere to pretty religiously! The one thing in this method that is different from normal vanilla DOM manipulation is that I append the elements that I create to the shadowRoot instead of the document or to the element directly! This just attaches the element to the shadow DOM instead of the root DOM of the document.

  render () {
    const div = document.createElement('div')
    div.classList.add('header')
    this.shadowRoot.appendChild(div)
    this.addSpans(div)
    this.addStyle()
  }

I then added the individual spans for each letter to the DOM, this is essentially identical to vanilla JavaScript code:

  addSpanEventListeners (span) {
    span.addEventListener('mouseover', () => { span.classList.add('hovered') })
    span.addEventListener('animationend', () => { span.classList.remove('hovered') })
  }

  createSpan (letter) {
    const span = document.createElement('span')
    span.classList.add('letter')
    span.innerHTML = letter
    this.addSpanEventListeners(span)
    return span
  }

  addSpans (div) {
    [...this.text].forEach(letter => {
      let span = this.createSpan(letter)
      div.appendChild(span)
    })
  }

Finally, I added the styling to the shadow DOM:

  addStyle () {
    const styleTag = document.createElement('style')
    styleTag.textContent = getStyle(this.size)
    this.shadowRoot.appendChild(styleTag)
  }

This method adds a style tag to the shadow DOM to modify the elements within it. I used a function to plug in the font-size of the header to a template literal that contained all of the CSS.

After writing the component, I had to register my new element:

try {
  customElements.define('rainbow-text', RainbowText)
} catch (err) {
  const h3 = document.createElement('h3')
  h3.innerHTML = "This site uses webcomponents which don't work in all browsers! Try this site in a browser that supports them!"
  document.body.appendChild(h3)
}

I also added a warning for users on non-webcomponent friendly browsers!

Here's how the element ended up showing up in the console:

Next Steps

I enjoyed working with web components! The idea of being able to create reusable components without a framework is awesome. The one I built will be really helpful for me since I use the multi-colored name so often. I will just include the script in other documents. I won't convert my personal site to using the component, though, since I want that to be supported across browsers. There also isn't a clear system for state or data management, which makes sense given the goal for web components; however, it does make other frontend frameworks still necessary. I think I will keep using frontend frameworks for these reasons; however, once they are fully supported, they will be awesome to use!

Full Code
Example Use - (doesn't use webcomponents)

Part of my On Learning New Things Series

Discussion (23)

pic
Editor guide
Collapse
nickytonline profile image
Nick Taylor (he/him) • Edited

I did a hello world in web components a couple of years ago, but haven't touched them since. Thanks for the refresher!

I'd just make one suggestion about adding event listeners. This is not web component specific, but for adding event listeners in general. In the case of the rainbow-text component, the number of <span />s increases for every additional letter in the text attribute, so the number of event listeners per instance of the component is n letters * 2 (mouse over + animation end events).

You can end up with a lot of events very quickly just for one instance of the component. What you can do is add an event listener for each event type on the parent <div /> you create in aspittel/rainbow-word-webcomponent and then the power of event bubbling is your friend.

e.g.

class RainbowText extends HTMLElement {
  …

  addEventListeners(div) {
    div.addEventListener("mouseover", e => {
      const { target } = e;

      if (target.tagName === "SPAN") {
        target.classList.add("hovered");
        console.log(`mousing over ${target.tagName}`);
      }
    });

    div.addEventListener("animationend", e => {
      const { target } = e;

      if (target.tagName === "SPAN") {
        target.classList.remove("hovered");
        console.log(`mousing over ${target.tagName}`);
      }
    });
  }

  …

  render() {
    const div = document.createElement("div");
    div.classList.add("header");
    this.addEventListeners(div);
    this.shadowRoot.appendChild(div);
    this.addSpans(div);
    this.addStyle();
  }
}
Collapse
aspittel profile image
Ali Spittel Author

Ah thank you for spotting that!

Collapse
washingtonsteven profile image
Steven Washington

Thanks for the intro to Web Components!

I'm curious how the browser knows what to render when it decides to "connect" your component. It seems like, from the naming, connectedCallback is called after the Dom expects something to exist, so there's a period of time while your function runs that there is nothing (not even a container) rendered? Or is that less a componentDidMount and more of a componentWillMount?

Also, is there any sort of poly fill or transplanting to get this in older browsers...or even just some automatic graceful degradation (like in your try..catch block).

Thanks again! This was very clear and straightforward intro!

Collapse
aspittel profile image
Ali Spittel Author

Awesome! Polymer is the best way to create web components that are cross-browser compatible. The API is somewhat different, but they are much more usable!

connectedCallback is called when the custom element is first connected to the document's DOM (according to the MDN documentation). So the container mounts to the DOM, then connectedCallback is triggered.

Collapse
smalluban profile image
Dominik Lubański

connectedCallback is called every time, when an element is attached to document, so also when you move an element from one parent to another. MDN definition:

connectedCallback: Invoked each time the custom element is appended into a document-connected element. This will happen each time the node is moved, and may happen before the element's contents have been fully parsed.

(developer.mozilla.org/en-US/docs/W...)

Collapse
jochemstoel profile image
Jochem Stoel • Edited

WebReflection on GitHub has a cross browser compatible polyfill of document.registerElement

Collapse
thipages_50 profile image
tit pa • Edited

Hi, thanks for this clear post.

Note that when running (Chrome 70), it says
[Deprecation] Element.createShadowRoot is deprecated
and will be removed in M73, around March 2019.
Please use Element.attachShadow instead.

Perhpas you may update the code (I couldnt ...)

Collapse
thipages_50 profile image
tit pa

works with

    if (!this.shadowRoot) {
        this.attachShadow({mode: 'open'});
    }
    //this.createShadowRoot();
Collapse
belhassen07 profile image
Belhassen Chelbi

I love your articles and your website is cool.
clapping

Collapse
smalluban profile image
Dominik Lubański • Edited

Very nice example of creating web components using only browser APIs :) If I may, in your full code example is few missing things:

  • this.createShadowRoot() is not defined, and it should be resistant to multiple calls (connectedCallback can be called multiple times)
  • The same problem is with your this.render() method. It appends new div, so every time it will be called (and it is called in connectedCallback), new div is appended to the shadowRoot. I assume, you should clear shadowRoot firstly (you can call this.shadowRoot.innerHTML = '').
  • Your post mentions about attributeChangedCallback, but the code example is not using it, so changing attributes does not re-render component (Also then you should use static computed property static get observedAttributes() - someone already wrote about it in a comment here)

Using raw APIs is really cool because we don't need any external tools for creating web components. However, then you and only you are responsible for matching standards, which ensure your custom element will work and will be usable :)

Did you consider using a 6kb library with an absolutely simple and unique API for building web components?

More or less, your example could be written like this (using hybrids library):

import { html, define } from 'hybrids';

function toggleSpanHover(host, { target }) {
  if (target.tagName.toLowerCase() === 'span') {
    target.classList.toggle('hovered');
  }
}

const RainbowText = {
  text: '',
  fontSize: 50,
  render: ({ text, fontSize }) => html`
    <div
      style="${{ fontSize: `${fontSize}px` }}"
      onmouseover="${toggleSpanHover}"
      onanimationend="${toggleSpanHover}"
    >
      ${text.split('').map(letter => html`
        <span>${letter}</span>
      `.key(letter))}
    </div>

    <style>
      /* your styles here */
    </style>
  `,
}

define('rainbow-text', RainbowText);

I know, that your whole idea was to not use libraries, but after all, they give you a solid abstraction on top of web APIs and ensures, that components work as expected.

Collapse
equinusocio profile image
Mattia Astorino

Hi, I wrote and article about the real scope of web components, which is extend the HTML.

You can read more about this point of view, that is also shared by Google Web Fundamentals

dev.to/clabuxd/web-componentsthe-r...

Collapse
vitalyt profile image
Vitaly Tomilov

This can help with writing DOM components in Vanilla JS:

github.com/vitaly-t/excellent

It simply turns all your DOM code into components, for better reusability, isolation, and instantiation patterns.

Collapse
007lva profile image
luigi

Safari already has web components

webkit.org/blog/4096/introducing-s...
webkit.org/blog/7027/introducing-c...

(I didn't try Shadow DOM yet, but at least native custom elements works fine to me)

Collapse
aspittel profile image
Ali Spittel Author

Oh cool! I was just going off of the MDN docs -- I don't really use Safari! Thanks for the heads up!

Collapse
maccabee profile image
Maccabee

Have you tried any of the other callbacks? I can't seem to get the attributeChangedCallback to fire.

Collapse
thatjoemoore profile image
Joseph Moore

For performance reasons, the browsers require you to define the list of attributes you want to listen to. You can do that by defining a static method called observedAttributes, which returns an array:

static get observedAttributes() {return ['my-attr', 'another-value']; }

That'll cause attributeChangedCallback to fire for changes to either of the listed attributes.

Collapse
maccabee profile image
Maccabee

Thanks a lot. I did get it to work with a setter but i still wanted to know how the callback works. I'll try it out when I can.

Collapse
adripanico profile image
Adrián Caballero

I assume that we will need to define the getStyle method, right?

Collapse
adripanico profile image
Adrián Caballero

OK, I found it in the complete example...

Collapse
plainjavascript profile image
plainJavaScript

You can use a CSS .header span instead of a .letter class for each span.

Collapse
annarankin profile image
Anna Rankin

Awesome!! This is the clearest writeup I've read of what web components actually do - thanks for the excellent run-through :D

Collapse
mashablair profile image
Maria Blair

This is such a great article! It serves as a very gentle intro to web components which I actually wanted to try for a while. Great job, Ali!