Get started with custom elements
Although custom elements API isn't too difficult, the way custom elements can be used can elude developers. This is particularly true for people who are new to vanilla JavaScript and working with DOM elements at a lower level.
In this post, I'll try to provide a reasonably gentle introduction to custom elements, and provide a few examples to help people wrap their brains around the topic. Of course, I cannot possibly cover everything there is to it, there are so many scenarios where custom elements could be used, many of them with their unique challenges. I will try my best, though, to cover most of the things you're likely to need on day-to-day basis.
Stock vs custom elements
Custom element is like a stock element, but custom.
It may sound funny, but that's the essence. In order to understand custom elements, you will also need to understand stock elements, and develop an intuition about how they work.
All DOM elements have a well-defined interface that you become familiar with as you manipulate them. These are:
- Properties and attributes
- Events
- Methods
Although DOM elements generally share most of the interfaces, many elements have
unique interfaces that only they support. For instance assigning to the value
property on a HTMLInputElement element will not only update the property
itself, but also the value attribute. It also updates some other internal
state to keep the input validity in sync. The HTMLDialogElement has a show()
method not present in other elements. The HTMLFormElement element can be
passed to the FormData() constructor, has an elements property, and a unique
'submit' event. The HTMLDetailsElement element emits a 'toggle' event that
other elements don't.
Like those 'special' stock elements, custom elements can also have custom
interfaces, like properties with special behaviors, custom events, and methods.
You can also implement any of those special interfaces found in other stock
elements, to make your custom elements behave like them. For example, you can
implement the value property to behave exactly like the one in the
HTMLInputElement, and you can make your custom element emit 'input' and
'change' events in response to user action.
In fact, unless you have a very good reason not to, implementing custom elements to behave similarly to stock elements is a worthwhile design goal as it makes their use more intuitive.
Custom elements are classes
Custom elements are subclasses of the
HTMLElement class.
Firstly, this means that it inherits the interfaces that this class provides, including those defined on:
Getting familiar with these interfaces and their features can be very useful when developing your own custom element class.
Secondly, it's just a class. You can do anything with it that you'd normally expect from a class: inherit from it, overload methods from the superclass' interface, etc.
You want to get comfortable with classes in JavaScript before you get cranking.
Special methods and properties
Custom elements have a few special methods and static properties that are unique to them.
The static observedAttributes is a property (or a getter) that returns an
array of attribute names that you wish to observe for changes. A companion
method attributeChangedCallback() is called whenever an observed attribute
changes. This method takes three attributes: the name of the attribute that
changed, the previous value, and the current value.
The static formAssociated is a property (or a getter) that returns a Boolean
value telling the browser whether this element behaves like a form control.
Simply setting this property to true doesn't make it a fully featured form
control by itself. You will need to do some more work. More on that later.
The connectedCallback() and disconnectedCallback() methods are called every
time the custom element is appended to a parent node or removed from one. Note
that I say 'every time'. This is important. For example, if your custom element
is going to be temporarily removed from the DOM and inserted in another location
(for instance, in drag & drop sorting), then this causes the element to be
disconnected and reconnected, triggering these methods.
The less commonly used adoptedCallback() is called when the element is moved
from one document to another, such as when using the
DOMParser to
render the custom element from a string, and then insert the rendered elements
into the page's document. If the custom element references the document it
belongs to in some way, you may need to update the reference in this callback.
As I said, it's not a very common design.
How to set up custom elements
By 'set up', I mean things like attaching event listeners, applying initial element state based on attributes, etc.
The class' constructor is invoked every time a new instance of the element
is created, either by parsing the HTML, or in a library such as React, or
manually by using document.createElement(). You can certainly do setup in
the constructor. For example like this:
class MyCustomElement extends HTMLElement {
static formAssociated = true
constructor() {
super()
var this_ = this
this.internals = this.attachInternals()
this.querySelector('select')
.addEventListener('change', function (ev) {
this_.internals.setFormValue(ev.target.value)
})
}
}
However, I do not recommend it. Depending on how the custom element subtree is created and what your setup code is doing, the constructor may not actually work. For example, let me give you an example of when the above constructor will fail:
customElements.define('x-my', MyCustomElement)
var x = document.createElement('x-my')
The example code will fail because when the element is created, there's no
<select> element in its subtree. This also happens to be how most of your
front end frameworks will create elements, so you will have the same issue.
A better approach is to do it in the connectedCallback(). This callback is
only invoked when the element is connected to its parent. Now, it's still
possible that you will run into code that first attaches the custom element
to the parent node and then appends its child nodes, but that's more of an
issue with such code. Most sound code won't do that.
The problem with connectedCallback() is that it's not called only once.
For example, if the element is removed from the DOM tree and re-inserted, it
means it is disconnected and then connected again. The connectedCallback()
will be called once when it is initially inserted into the DOM tree, and
then every subsequent time it is re-inserted. This is not great for the
purposes of initial setup as it means event listeners would be reattached
multiple-times resulting in wonky behavior.
I recommend using the following pattern:
class MyCustomElement extends HTMLElement {
static formAssociated = true
connectedCallback() {
this.setup?.()
delete this.setup
}
setup() {
var this_ = this
this.internals = this.attachInternals()
this.querySelector('select')
.addEventListener('change', function (ev) {
this_.internals.setFormValue(ev.target.value)
})
}
}
This is what I call an IDM - immediately-deleted method pattern. It's a method
of creating one-off methods that are removed immediately after first use.
With this approach, I have a one-time setup handled by the setup() method,
and the connectedCallback() can continue to do it's thing after that.
Handling global events
There's stuff the connectedCallback() could legitimately do every time the
element is connected, and the same applies to the disconnectedCallback(). One
of those things is registration of global event listeners (the ones attached to
windows or elements outside the custom element, like <body>).
When you attach global event listeners in the connectedCallback(), you also
need to make sure they are removed when the element is disconnected. If the
element is then re-connected, then you need to make sure those listeners are
attached again. Removing event listeners can be done one of two ways.
- You can call the
removeEventListener()and pass in the event type and the original listener function - You can pass an
AbortSignal(thesignalproperty of anAbortControllerobject) object toaddEventListener()and remove the listener by aborting a controller
Keeping references to functions between two methods in a class is a bit dirty for my taste, so I prefer the second approach these days. Let me give you an example:
class MyCustomElement extends HTMLElement {
// Abort controller is a private property so it
// needs to be declared explicitly. It is private
// so that the code outside the custom element cannot
// accidatally (or intentionally) remove event
// listeners.
#abortController
connectedCallback() {
// New instance of AbortController is created every
// time the element is connected.
this.#abortController = new AbortController()
window.addEventListener(
'pointermove',
updateCoordinates.bind(this),
{signal: this.#abortController.signal},
)
function updateCoordinates() {
this.querySelector('.x').textContent = ev.screenX
this.querySelector('.y').textContent = ev.screenY
}
}
disconnectedCallback() {
// This causes all event listeners added in the
// connectedCallback() to be removed
this.#abortController.abort()
}
}
How to use attributes and properties
I mentioned earlier that elements like HTMLInputElement have a value
property that doubles as a value attribute. They also have a disabled
property that works slightly differently: instead of assigning a value to
the respective attribute, it toggles the attribute on or off. You can
replicate this type of behavior in the custom element by using getters and
setters.
class MyCustomInput {
get value() {
// When the attribute is not present, it
// evaluates to null. We provide an
// alternative default for the value
// attribute.
return this.getAttribute('value') || ''
}
set value(newValue) {
this.setAttribute('value', newValue)
}
get disabled() {
return this.hasAttribute('disabled')
}
set disabled(newValue) {
this.toggleAttribute('disabled', newValue)
}
}
If an attribute name is mentioned in static observedAttributes, and the value
of the attribute changes, the attributeChanged() callback will be invoked. It
doesn't matter if the attribute was set from the outside (e.g., by calling
myCustomElement.setAttribute()) or from within (e.g., this.setAttribute()).
If the attribute changed, it will trigger the callback.
class MyCustomImput {
satic observedAttributes = ['value', 'disabled']
// ....
attributeChangedCallback(name, previousValue, value) {
console.log(`${name} changed: ${previousValue} -> ${value}`)
}
}
My experience is that trying to make the getters and setters too clever will
ultimately backfire. Therefore, I like to keep the attribute setters simple, and
not do much more than just set the matching attribute. If I need to do extra
work when an attribute is set, I'll do it in the attributeChangedCallback().
Supplying a default value when an attribute is missing is fine, and so is
serializing the value into a string in the setter. Beyond that, you probably
void your warranty.
In the following example, I'll take three attributes, min, max, and value, and I'll draw a progress bar that represents those values.
Let me first set up the attribute aliases.
class MyProgress {
get value() {
return Number(this.getAttribute('value'))
}
set value(newValue) {
this.setAttribute('value', newValue)
}
get min() {
return Number(this.getAttribute('min'))
}
set min(newValue) {
this.setAttribute('min', newValue)
}
get max() {
return Number(this.getAttribute('max'))
}
set max(newValue) {
this.setAttribute('max', newValue)
}
}
You will notice that this time I coerce the values in the getters. Since all values must be numeric, I might as well take care of that at the source. The earlier the better.
Now, let me tackle the attribute change. For the actual progress bar, I'm going
to use the <progress> element. This element takes the same max and value
attributes as our custom element, but these values all need to be floating point
numbers between 0 and 1. The min is always 0. Therefore, I need to translate
our min, max and value values to the range between 0 and 1.
class MyProgress {
// ....
attributeChangedCallback() {
var valueRange = this.max - this.min
var progressValue = (this.value - this.min) / range
this.querySelector('progress').value = progressValue
}
}
From the software design perspective, you can think of this as a pull observable — one where the observer is only notified of changes and is expected to 'pull' the new values on its own, as opposed to a push observable which sends the updated values to the observer.
Using custom elements to instrument the DOM
The first use case for custom elements is instrumenting the DOM nodes in their subtree. I've already showed you an example of this usage with the example of a progress bar in the previous section, but let me show you a slightly more complex one.
Let me show you an an example of a tabbed interface. The HTML for this example will look like this:
<x-tabbed active="latest">
<ul class="tab-list">
<li><a href="#latest">Latest</a></li>
<li><a href="#popular">Popular</a><li>
</ul>
<section id="latest">....</section>
<section id="popular">....</section>
</x-tabbed>
The idea here is that I want the links 'Latest' and 'Popular' to activate the respective sections. The general approach here is that I want the underlying HTML to be as closed to fully functional as possible without the custom element. Clicking the links will simply jump the user to the appropriate section as links usually do. It won't be a "tabbed" interface, but it will work.
This is technically progressive enhancement. I do it this way because this gives me better accessibility. If I can get the essential behavior working without JavaScript, there's less I need to explain to the screen readers — as long as I don't accidentally break the basic behavior, that is.
Now, to turn this into the tabbed interface, I will write a custom element. The
general idea is that I want the active attribute to control the state of the
tabs. This allows me to set the default active tab in the HTML without declaring
any special constants for it or writing additional logic.
For this implementation, I'll keep things simple and assume that the
<x-tabbed> is never going to be removed from the DOM, which means I don't have
to worry about performing cleanup when it gets disconnected.
I'll start with marking the 'active' attribute as observed, and adding the getters and setters.
customElements.define('x-tabbed', class extends HTMLElement {
static observedAttributes = ['active']
get active() {
return this.getAttribute('active')
}
set active(tabId) {
this.setAttribute('active', tabId)
}
})
Since this is a relatively simple element, I'll implement the logic related to
toggling the tabs within the attributeChangedCallback(). One thing to note
here is that the callback is also called once initially if the observed
attribute is already set when the element is created. This is a good example
of designs that eliminate exceptions (initial state vs later changes are
treated the same way).
customElements.define('x-tabbed', class extends HTMLElement {
// ....
attributeChangedCallback() {
// First unset `aria-current` on all links, and then
// set it to `true` only for the active one
this.querySelector('[aria-current]').removeAttribute('aria-current')
this.querySelector(`a[href="#${this.active}"]`)
.setAttribute('aria-current', true)
// Hide any non-hidden sections, and unhide the section
// that is now active.
this.querySelector('section:not([hidden])').hidden = true
this.querySelector('#' + this.active).hidden = false
}
})
Lastly, I'll instrument the links.
customElements.define('x-tabbed', class extends HTMLElement {
// ....
connectedCallback() {
var this_ = this
this.querySelector('.tab-list')
.addEventListener('click', function (ev) {
// This is a delegated event listener so I need to
// first check if I've got the right target.
if (!ev.target.matches('a')) return
var targetTab = ev.target.getAttribute('href').slice(1)
this_.active = targetTab
})
}
})
Using custom elements as form controls
The second use case for custom elements is form controls. In most web applications, forms play a central role. The browser covers a wide range of form-related functionality, and there exists many built-in mechanisms that are available to native form controls (e.g, constraint validation). While it's relatively easy to create custom controls with what I've discussed in the previous sections, going the extra mile to fully integrate the custom elements into the native forms will make a lot of things easier in the long run.
There is one thing I want to note before I proceed with the discussion of how to do this, and that's when not to do this. Although custom elements can be used to build powerful form controls, do take your time to consider using built-in form controls first. With some creative use of CSS, you can easily implement things like toggles, button groups, auto-complete inputs, and similar without resorting to custom elements.
Form-associated or not?
Custom elements can be 'form-associated' (having a static formAssociated
property which evaluates to a truthy value) or non-form-associated.
The non-form-associated custom elements are exactly like the ones you've seen in my previous examples. They don't actually play the role of a form control. They exist solely to add behaviors to the elements that play the role of the actual form control.
The form-associated custom elements are themselves form controls, and any
form controls found within it are there to take inputs from the user. In
this case, the form controls that are inside the custom element are not part
of the form (e.g., they don't have the name attribute). I will give you an
example one such custom element at the end of this post.
You will generally want to gain some experience using both types of custom elements and decide which pattern works better for the particular control you're trying to build. Here's the general rule I follow:
- if a standard control can work on its own without additional code, and the custom element merely provides some cosmetic/convenience enhancements, then I'll go with non-form-controlled custom element as that results in less complex code
- if I'm trying to make non-form-control elements behave like form controls, or I have multiple stock form controls behaving as a single form control then I'm probably better off making the custom element form-associated
Some examples of the first option include things like date picker or an autocomplete input, where the text input serves as the form control, and the custom element wraps it to provide enhancement such as calendars or completion lists.
The second option always requires a lot more code and is almost always less accessible by default. I generally want a very good reason to opt for that. That's not to say that there aren't good reasons to do that. One example of this type of control might be an OTP code control (provided as an example at the end of the post) which has several text inputs playing the role of a single input.
For the remainder of the post, I will focus on the extra bits and pieces you need to build a form-associated custom element.
The control value
First of all, you will decide if the form control handles complex values. By
'complex values', I mean values that cannot be (easily) serialized as a string.
Think <input type="file">. When you set a file on the file input, it isn't
available through the value attribute like all other form values. Instead, it
is made available through the files property. This is a perfectly valid
pattern, but it is also quite inconvenient for the developer using your
component. Another pattern is the checkbox one where the value attribute is
used in combination with its checked state to determine the actual value that
will be submitted. A vast majority of form controls don't handle complex values
and their value can be represented using strings.
You will decide which of these patterns (or a new one, perhaps) you will use and what the value attribute will represent.
Implementation-wise, other than adding getters and setters for the value
attribute (and anything else you decide is related to the control's value), you
also need to do a few more things.
You need to attach the element internals by calling the
attachInternals()
method on the custom element (this method is present on all elements in the DOM,
btw), and keep a reference to its return
value. The
internals' setFormValue() method is what you use to let the browser know what
the actual value of the control is (known as 'form value'). This is what is
going to be retrieved by FormData(), for example. To make the control more
ergonomic, you will generally want this value to be in sync with the value
attribute.
Additionally, if you're handling complex values, you may need another property or a method to retrieve the complex version.
Validation
You will also want to think about the validation. The standard — and I say
standard, not common, easy, or usual — way to do validation client-side is to
specify the validation parameters through attributes. For instance, do you want
your custom element to be able to support a required attribute or maybe min
or max? Anything else, like some custom attribute — remember that custom
elements can have any attribute it likes. You will also want to consider
allowing the user to set their own custom validation error message — like you
can do with stock form controls — by calling the
setCustomValidity()
method — of the form control is generic enough.
The general idea with validation is that you perform it whenever the value changes. This doesn't necessarily mean that the user is going to see the error message as soon as you update the validation status. The constraints validation API cleverly separates validation from the presentation of error messages and form behavior in case there are errors. Validation status is maintained separate of those things.
Whenever you update the form value of the control, you will generally
perform validation on the spot. To set the validation status, you will need
a reference to the element internals, and you will call its
setValidity()
method.
Note that the setValidity() method takes an anchor element as one of its
arguments. The anchor is an element that will be focused when the form is
submitted and the control is invalid. The error popup will also be presented
next to the anchor. If you don't set an anchor, the the custom element itself
will be focused if it's focusable (it isn't by default). It's important to
select a logical target for this behavior as that's where the user starts fixing
the error.
Control label behavior
Remember that form controls can be associated with labels. This also applies to custom elements. When you click on the label, the usual behavior of the stock controls is to receive a click. Buttons are literally clicked, text inputs receive focus because that's what happens when they are clicked, and so on. You want to make sure that your custom control responds to the user clicking on a label, because that's the expectation set by native controls.
One caveat regarding the element receiving a click via the label is that there
is no fool-proof way to differentiate between click happening on the element
versus the clicks happening on or within the element. One way to work around
this is to test whether the click event target is identical to
currentTarget. With this, you at least know that it isn't a click even
bubbling from one of the elements in the subtree.
Once you receive the click, you also need to decide what to do with it. Do you focus one of the actual controls in the subtree, etc. Incidentally, the target of the focus action should usually be the same target that receives focus on validation errors. Anyway, think about what the first logical step is for the user when using your custom control.
Stock controls can be wrapped in a <label> element. Inside the <label>
element, we are restricted in what elements we can use. For example, we cannot
use block-level elements, and we cannot use other labels, fieldsets, etc. In
many cases, the form controls that we use inside the custom element need to be
labelled. Instead of using a label element, I normally use aria-label or
aria-labelledby in combination with a visible element, depending on the use
case. Although shadow DOM is intended to address this issue, it brings with it a
different set of problems that I'd rather not have to deal with.
Disabled and read-only states
Form controls can be disabled and/or made read-only. Typically, this is
achieved by setting the disabled and readonly attributes, respectively.
If those states make sense to your form control, you want to implement them
to be close to the stock element behavior as possible.
In addition to whatever you need to do in order to make the control disabled
or read-only, you will also want to set the appropriate values of the
ariaDisabled and/or ariaReadonly properties on the element internals.
This maximizes the compatibility with accessibility technologies.
Example code
You can find a complete example of a custom form control for entering confirmation codes in a pen I've created.