Link Search Menu Expand Document

Module 1: Introducing Custom Elements

The first of the core technologies behind web components is the custom elements API, which offers something developers have been asking for since the earliest days of the web: a way to define their own HTML tags.

When I’m designing reusable components and modules, I’ll often start with the developer experience (DX) and work backwards – first I’ll think “what would be a really nice way to use this component?”, and then figure out what I need to do to make a component that works that way.

So, here’s the code I want to be able to write. A really simple “hello world” web component.

<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <hello-world />
    </body>
</html>

Open that in a browser, and you get… nothing. Blank page. There’s no such HTML tag as hello-world - and so the browser has no idea how to interpret that chunk of markup.

If we make hello-world a container tag and put something inside that tag, the browser will show it:

<body>
    <hello-world>Hello World.</hello-world>
</body>

but that’s a standard behaviour that’s been around forever – one of the things that has made the web such an accessible and successful platform is that browsers are generally very forgiving of markup and syntax they don’t understand; they’ll just ignore it and keep on trucking.

I said earlier that web components is really the combination of three new technologies - custom elements, HTML templates, and something called the shadow DOM. Let’s learn about the first one of those: custom elements.

We’re going to create a <hello-world> HTML tag, and register that as a custom element so the browser knows how to display it. Custom elements are written in JavaScript. Not React, Angular, TypeScript, vueJS - they’re written in plain JavaScript. They use JavaScript classes, which were introduced as part of ES6 in 2015; ES6 classes include classical inheritance via the extends keyword, and custom elements are defined by extending one of the built-in HTML elements and adding some new behaviour.

⚠ Custom tag names must include a hyphen (-) character, because the HTML spec guarantees that no built-in HTML tag will ever include a hyphen in the tag name. By requiring that tag names contain a hyphen, we guarantee that our custom tag names will never conflict with a built-in tag in a future version of HTML. I think that’s beautifully elegant.

Here’s the full code for the simplest possible custom element - hello-world.html

<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <hello-world></hello-world>
    </body>
    <script>
        class HelloWorldElement extends HTMLElement {
            // custom elements must declare a constructor
            constructor() {
                // before doing anything special, we call the
                // constructor on the base object that we're extending
                super()
                this.innerHTML = "Hello, World";
            }
        }
        customElements.define("hello-world", HelloWorldElement);
    </script>
</html>

Working with connectedCallback

In the example above, we’re injecting content directly into the component by assigning innerHTML inside the constructor:

constructor() {  
  super()
  // This will only work if innerHTML doesn't contain any child tags.
  this.innerHTML = "Hello, World";
}

This works for our really simple “hello world” example, but it’s not how we’re supposed to use it - we can’t inject any markup that contains child HTML elements, for example. Instead, what we should do is define a method called connectedCallback - the browser will run this method when it creates an instance of our component and connects it to the page DOM.

Take a look at hello-world-connected-callback.html to see how to use connectedCallback to set up your component:

<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <hello-world></hello-world>
    </body>
    <script>
        class HelloWorldElement extends HTMLElement {
            // if all the constructor does is call super(), we don't need it
            // constructor() {
            //   super();                
            // }

            connectedCallback() {
                this.innerHTML = "<p>Hello, World (via innerHTML)</p>";
                // or alternatively
                let content = document.createElement('p');
                content.innerHTML = "Hello, World (via createElement and appendChild)";
                this.appendChild(content);
            }
        }
        customElements.define("hello-world", HelloWorldElement);
    </script>
</html>

Attributes on Custom Elements

Just like regular HTML elements, custom elements can support attributes, so we can write markup like:

<hello-world name="Alice"></hello-world>

to get an output like:

Hello Alice

Because our custom element extends HTMLElement, we inherit a whole bunch of useful methods we can use, including one called getAttribute(), which we can call to retrieve the value of the attribute that was specified in the markup.

Custom attributes are always strings.

If you specify name="12345", this.getAttribute("name") will return the string "12345", not the number 12345.0

If an attribute is omitted completely, getAttribute() will return null - but if you provide an empty string (or just reference the attribute without providing a value), getAttribute() returns the empty string.

<my-tag></my-tag>         // getAttribute('foo') will return null
<my-tag foo></my-tag>     // getAttribute('') will return ""
<my-tag foo=""></my-tag>  // getAttribute('') will return ""

Checking for null vs "" means we can use attributes that don’t take any value, similar to how <input disabled/> or <option selected></option> work in regular HTML.

Handling changes to attributes via attributeChangedCallback

Custom components can implement the attributeChangedCallback method to be notified if the host page changes an attribute value via JavaScript.

You need to expose a list of attribute names via the static get observedAttributes() method inside your custom class if you want your component to receive notifications about those attributes

Take a look at hello-world-attribute-changed-callback.html to see this callback in action:

<!DOCTYPE html>
<html>

<head>
    <title>Hello, World!</title>
    <script src="hello-world.js"></script>
</head>

<body>
    <hello-world id="hello-world" name="World"></hello-world>
    <hr />
    Update: <input id="greeting-name">
    <button id="update-greeting-button">Update</button>
</body>
<script>
    let button = document.getElementById('update-greeting-button');
    button.addEventListener('click', function () {
        let value = document.getElementById('greeting-name').value;
        let element = document.getElementById('hello-world');
        element.setAttribute("name", value);
    });
</script>

</html>

The component code used on this page is in hello-world.js:

class HelloWorldElement extends HTMLElement {

    greet(name) {
        name = name || "World";
        this.innerHTML = `<p>Hello, ${name}</p>`;
    }

    connectedCallback() {
        let value = this.getAttribute('name');
        this.greet(value);
    }

    // The browser will call attributeChangedCallback for any attribute whose
    // name is exposed in observedAttributes
    static get observedAttributes() { return ['name']; }

    attributeChangedCallback(attributeName, oldValue, newValue) {
        console.log(`Attribute ${attributeName} changed from ${oldValue} to ${newValue}`);
        this.greet(newValue);
    }
}
customElements.define("hello-world", HelloWorldElement);

Extending native HTML elements

So far, we’ve only built custom elements that extend HTMLElement. We can also extend many of the built-in HTML tags to create special elements that add custom behaviour to standard HTML elements.

⚠ Customized built-in elements are currently not supported in Safari, either on macOS or on iOS

To extend a native HTML element, you need to remember three things:

  1. Your JS class needs to extend from the element you’re extending - HTMLParagraphElement, HTMLButtonElement, HTMLInputElement

  2. You need to specify which tag you’re extending when you define the custom element:

    customElements.define('tag-name', CustomElement, { extends: 'tag' });

  3. You need to use the is attribute when writing your markup:

    <p is="my-custom-paragraph">This paragraph has superpowers!</p>

Here’s an example of a custom element that extends the built-in HTML paragraph tag to show the word count when you hover the mouse over a paragraph:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Customized Built-in Elements</title>
</head>

<body>
    <p is="word-count">This paragraph has five words.</p>
    <p is="word-count">This paragraph is a little longer and contains ten words.</p>
    <p is="word-count">This is the longest paragraph in the document. It contains twenty words. Some of the
        words are not very interesting.</p>
    <script>
        class WordCountParagraphElement extends HTMLParagraphElement {
            constructor() {
                super();
                let wordCount = this.innerText.split(/\s+/).length;
                this.title = `${wordCount} words  in this paragraph`;
            }
        }
        customElements.define('word-count', WordCountParagraphElement, { extends: "p" });
    </script>
</body>

</html>

Customized built-in elements offer a form of progressive enhancement; because browsers that don’t support them will ignore the is attribute and render them as the native elements they extend.

Introducing Custom Elements: Review

We’ll take another look at custom elements later in the workshop, but here are the key points we’ve covered so far:

  • Custom elements is a browser technology that allows us to register new HTML tags and use them alongside builtin tags like <div> and <input>
  • A custom element must be defined as a JavaScript ES6 class that extends from HTMLElement or from a built-in HTML element.
  • Custom elements can have attributes like any other HTML tag; these can be retrieved via the getAttribute() method
  • Custom elements that inherit from HTMLElement are known as autonomous custom elements
  • Custom elements that inherit from a built-in tag such as HTMLParagraphElement are known as customised built-in elements

Exercise: creating a custom element

Create a custom HTML element with the following behaviour:

  • The tag should be called friendly-greeting
  • It will display a friendly greeting. 🙂
  • Users can specify who should be greeted with the attribute name. If no attribute is supplied, it defaults to ‘World’.
  • Users can specify a language for the greeting with the lang attribute. Languages should be provided as a 2-letter ISO-369-1 language code. Support at least one language other than English, and fall back to English if the specified language is not supported.
  • Users can force the greeting to be in ALL CAPS by adding an attribute shout

Examples:

  • <friendly-greeting></friendly-greeting>will display Hello World!
  • <friendly-greeting shout></friendly-greeting> will display HELLO WORLD!
  • <friendly-greeting name="Bob"></friendly-greeting> will display Hello Bob!
  • <friendly-greeting name="Bob" lang="uk"></friendly-greeting> will display Привіт, Bob!
  • <friendly-greeting name="Bob" lang="uk" shout></friendly-greeting> will display ПРИВІТ, BOB!

Resources and Further Reading