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 number12345.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:
-
Your JS class needs to
extend
from the element you’re extending -HTMLParagraphElement
,HTMLButtonElement
,HTMLInputElement
-
You need to specify which tag you’re extending when you define the custom element:
customElements.define('tag-name', CustomElement, { extends: 'tag' });
-
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
- The Custom Elements spec at WHATWG
- Using custom elements on Mozilla Developer Network
- Custom element support on caniuse.com
- Classical vs Prototypal Inheritance by @crishanks at dev.to
- Danny Moerkerke has a great article about customising built-in HTML elements to create a custom
<IMG>
tag that supports lazy loading, and the code is available on GitHub