All about HTML Custom Elements
Custom Elements is a WHATWG HTML specification that
provides a mechanism for defining new behaviors (such as dynamic
content or interactivity) for HTML elements with custom names.
Custom elements are just HTML elements, with all of the methods and
properties of other, built-in elements. The only real constraint
is that
custom element names must contain a hyphen (-
).
<message-element>Hi!</message-element>
<super-section>
<p>Custom elements can contain content!</p>
<message-element>And other custom elements!</message-element>
</super-section>
Note: The adopted custom element spec, formerly known
as "v1", differs almost entirely from the original "v0" spec.
If you've been using document.registerElement()
from the v0 API,
then read on to see what's changed.
Encapsulation. Custom element names avoid ambiguity in
markup (versus, say, a <div>
or <span>
with a "special"
class), and provide a solid foundation for scoped styles.
If you've ever felt like it was wrong to define reuseable components with "special" class names initialized by jQuery selections that can only be called after the DOM is ready (or rely on mutation observers to watch for new instances), then custom elements could be your new jam.
In native implementations (see browser support),
you can target custom elements before (:unresolved
in the
v0 spec) and after (:defined
in the v1 spec) they've
been registered via JavaScript.
The DOM is the API. Components built with tools like jQuery
can be cumbersome to build, modify, and maintain because they often
introduce another layer of abstraction (such as the jQuery
object
and its API) on top of the DOM. And while there's nothing to stop
one from building custom elements that use jQuery, D3, React, or
whatever under the hood, I've found custom elements made with
vanilla JS to be easier to grok and read.
Put another way: The DOM isn't going away any time soon, and custom elements provide a solid conceptual and technical foundation on which all sorts of amazing things can be built.
Web Standards. Custom elements are an adopted WHATWG HTML specification. That means that—in theory, at least—they will eventually be implemented natively in all modern browsers with the same API.
Keep in mind is that custom elements are not mutually exclusive of other web component technologies. In fact, I see them as a powerful force multiplier of any technology that leverages them: When designed well, custom elements put their power into the hands of anyone who can write HTML. Great React components, for instance, can be made even greater by packaging them up as custom elements.
Custom element behaviors are added at runtime (whenever the "registration" occurs in JavaScript), and can hook into a number of different element lifecycle events:
document.createElement()
; or when
existing custom elements are registered.As of summer 2018:
Regardless of your support targets, you should use a polyfill.
⚠️ When using custom elements—or anything involving JavaScript, for that matter—always design experiences for progressive enhancement, and plan for the possibility that JavaScript isn't enabled or available.
The custom elements API consists mainly of a CustomElementRegistry object that can be used to register class constructors for custom elements by name:
window.customElements.define('element-name', ElementClass);
Where ElementClass
is a class that extends HTMLElement:
// ES2015
class ElementClass extends HTMLElement {
constructor() {
super() // <-- this is required!
this.created()
}
created() {
console.log('hi!', this)
}
}
Custom element classes may implement any of the following lifecycle (instance) methods:
connectedCallback()
is called whenever the element is added to
the document, either directly (document.body.appendChild(el)
) or indirectly
(as part of a fragment or a DOM tree that's added to the document).disconnectedCallback()
is called whenever the element is removed from
the document.attributeChangedCallback(attr, oldValue, newValue)
is called when
an observed attribute (see below) is changed.and the following static (class) properties:
observedAttributes
is an optional array of attribute names for
which the attributeChangedCallback()
will be called. If you do not
provide this property, the callback will fire for all attributes.// ES5
class CounterElement extends HTMLElement {
static get observedAttributes() { return ['value'] }
attributeChangedCallback(name, old, value) {
// we can safely ignore name here because 'value' is the only
// observed attribute
this.value = value
}
get value() {
return ('_value' in this)
? this._value
: (this._value = 0)
}
set value(value) {
this._value = value
}
}
⚠️ Warning: Safari does not yet implement this portion of the spec. If you wish to use it, you will need a polyfill.
Custom elements may extend built-in HTML elements with special
semantics or behaviors (such as <button>
or <input>
). Here's how
they work:
Register the element with an additional argument indicating which element name it extends:
window.customElements.define('fancy-button', FancyButton, {
extends: 'button'
})
Instantiate the element in HTML with the is
attribute of the
extended built-in set to the name of the custom element:
<!-- this: -->
<button is="fancy-button">I am fancy</button>
<!-- NOT this: -->
<fancy-button>I am not fancy</fancy-button>
Instantiate the element in JavaScript by passing an additional
argument to document.createElement()
with the is
property
set to the name of the custom element:
var fancy = document.createElement('button', {is: 'fancy-button'})
Observed attributes are attributes that fire the attributeChangedCallback()
lifecycle method. If the custom element class has a (static)
observedAttributes
array, the callback will fire for only the listed
attributes:
// ES2015
class CustomElement extends HTMLElement {
static get observedAttributes() { return ['foo', 'bar']; }
attributeChangedCallback(attr, old, value) {
// `attr` will only ever equal 'foo' or 'bar'
switch (attr) {
case 'foo':
break
case 'bar':
break
}
}
}
Otherwise, the callback will be fired for all attributes.
// ES2015
class CustomElement extends HTMLElement {
attributeChangedCallback(attr, old, value) {
// `attr` could be anything
}
}
📝 When implementing attribute reflection, please observe the W3C API Design Principles.
The CustomElementRegistry object available at window.customElements
has two additional methods for querying its state and responding to when
specific custom elements are registered:
customElements.get('element-name')
returns the class constructor
of the provided custom element name (or undefined
if it hasn't been
defined).
customElements.whenDefined('element-name')
returns a Promise that
resolves if/when the named custom element is defined via
customElements.define()
.
You can listen for and dispatch custom events in custom
elements. The only bummer is that, even though most modern browsers
support the CustomEvent
constructor, it's missing in all versions of IE
and in older versions of PhantomJS, which is used for lots of "headless"
integration testing. My advice is to include
this polyfill, which falls
back on the native implementation. Here's how you could have your component
"announce" its readiness to the rest of the document, for instance:
var CustomEvent = require('custom-event');
document.registerElement('my-element', {
prototype: Object.create(
HTMLElement.prototype,
{
attachedCallback: {value: function() {
this.dispatchEvent(new CustomEvent('my-element-ready'));
}}
}
)
});
Because you need a polyfill and namespaces are tricky, it's
basically impossible to reliably extend SVG elements, or any element that
requires an XML namespace. Your best bet is to write a component that wraps
<svg>
elements or creates them at runtime if they don't exist.
One of the trickiest things about custom elements is the magical incantation
for defining element classes that extend HTMLElement
or its subclasses,
especially in "legacy" ES5 environments that don't support the class
keyword
or super()
calls. There are a couple of ways to pull it off:
Create an object literal (rather than a proper constructor function) with
a prototype
that extends HTMLElement.prototype
. The only way to do this
in a single expression is to use Object.create()
, which
extends the first argument with descriptors in the second. The important
thing to note here is that because these are property descriptors, methods
must be provided as objects with a value
property:
// ES5
var CustomElement = {
prototype: Object.create(
HTMLElement.prototype,
{
// this will NOT work:
createdCallback: function() {
},
// but this will:
createdCallback: {value: function() {
}},
// accessors look like this:
someValue: {
get: function() { /* ... */ },
set: function(value) { /* ... */ }
}
}
)
};
Note: if you need to support older browsers such as IE8 or below, you will also need a polyfill or shim for ES5 standard APIs, such as aight or es5-shim.
A variation on the above method uses Object.create()
but assigns methods directly:
// ES5
var CustomElement = {
prototype: Object.create(HTMLElement.prototype)
};
CustomElement.prototype.someMethod = function(arg) { /* ... */ };
// any accessors not passed to Object.create() can be defined like so.
// note that this is *exactly* what Object.create() is doing under the
// hood!
Object.defineProperties(CustomElement.prototype, {
someValue: {
get: function() { /* ... */ },
set: function(value) { /* ... */ }
}
});
Use Babel and the custom-element-classes transform. Your .babelrc
should look something like this:
{
"presets": ["env"],
"plugins": [
"transform-custom-element-classes"
]
}
which should make it possible to write classes like:
class Widget extends HTMLElement {
constructor() {
super()
}
}
window.customElements.define('widget-element', Widget)
The semi-official webcomponents/custom-elements polyfill is what GitHub uses,
and it provides a bunch of workarounds for the spec rules involving class
constructors and the new
keyword. You should use it, too!
<template>
elements.