Beyond <div> soup

Open almost any modern web application and you will find a familiar problem: a page structure built from layers of nested <div> elements. The markup is nearly impossible to scan by eye, and it does nothing to communicate what the application actually does. Custom Elements address this directly by letting developers define their own HTML elements with meaningful names, bundled behavior, and reusable structure.

Consider how much clearer an application becomes when its markup describes its purpose. Instead of a generic hierarchy of containers, an interface could declare its own components like a <horizontal-layout> containing a <search-box> and tabs. That structure is immediately understandable and far easier to maintain.

Registering a new element

The entry point for defining a custom element is document.registerElement(), which teaches the browser about a new tag and returns a constructor for creating instances of it:

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

The first argument is the element's tag name, which must contain a dash. Names like <x-tags>, <my-element>, and <my-awesome-app> are valid; <tabs> and <foo_bar> are not. This rule keeps custom elements distinct from standard HTML tags and reserves room for future additions to the specification.

The optional second argument is an object describing the element's prototype. This is where custom functionality such as public properties and methods is defined. By default, new elements inherit from HTMLElement:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

Custom elements can also extend native HTML elements. To create a "Mega Button" that builds on <button>, inherit from HTMLButtonElement and pass the source tag name

These are known as type extension custom elements, because they inherit from a specialized version of HTMLElement — effectively declaring "element X is a Y." Extending another custom element works the same way: inherit its prototype and specify the tag name in the registration call:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

How elements are upgraded

The HTML parser happily accepts unknown tags like <randomtag>, but such elements are given HTMLUnknownElement as their constructor. Custom elements are different: elements with valid custom element names inherit from HTMLElement even before their definition is registered. This behavior can be verified directly in the browser console:

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

Because registration happens in script, custom elements can exist in the DOM before their definition is registered. Before upgrade, these are called unresolved elements. The distinction breaks down like this:

Name Inherits from Examples
Unresolved element HTMLElement <x-tabs>, <my-element>
Unknown element HTMLUnknownElement <tabs>, <foo_bar>

Creating instances

Custom elements support the same instantiation techniques as standard elements. They can be declared directly in HTML, created via the DOM API, or constructed with the new operator:

<x-foo></x-foo>
var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});
var xFoo = new XFoo();
document.body.appendChild(xFoo);

Type extensions follow the same patterns, but require an overloaded version of document.createElement() that takes the is="" attribute as its second parameter:

<!-- <button> "is a" mega button -->
<button is="mega-button">
var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true
var megaButton = new MegaButton();
document.body.appendChild(megaButton);

Adding a JavaScript API

The real power of custom elements lies in defining additional properties and methods on the prototype, effectively creating a public API for the element. A full example:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

The prototype can also be constructed with Object.create(), which allows the use of getters and setters:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

Lifecycle callbacks

Custom elements can hook into their own existence through lifecycle callbacks, optional methods invoked at specific moments:

Callback name Called when
createdCallback an instance of the element is created
attachedCallback an instance was inserted into the document
detachedCallback an instance was removed from the document
attributeChangedCallback(attrName, oldVal, newVal) an attribute was added, removed, or updated

As an example, createdCallback() and attachedCallback() can be defined to perform setup when an element is created or inserted into the DOM:

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

Lifecycle callbacks are useful for adding default event listeners and setting up resources. A complex element that opens an IndexedDB connection in createdCallback(), for instance, should clean up in detachedCallback() — though cleanup should not be relied on if the browser tab is closed.

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

Adding markup and Shadow DOM

An element with only a JavaScript API renders empty. The createdCallback() is the ideal spot to populate default HTML:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

Inspecting the element in DevTools shows its contents:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

Shadow DOM takes this further by hiding an element's internals and providing style encapsulation. Instead of setting .innerHTML, the element creates a shadow root and fills it with markup:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});
▾<x-foo-shadowdom>
  ▾#shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

Building from templates

HTML Templates combine naturally with custom elements. A tag can be registered whose contents come from a <template> and whose internals live inside Shadow DOM:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

This approach accomplishes several goals at once. The new element is declared in markup, its structure comes from a template, its implementation details are hidden in Shadow DOM, and its internal styles — like a rule targeting p — remain scoped to the element rather than leaking into the page.

Styling Custom Elements and Handling FOUC

Custom elements follow the same styling rules as any other HTML tag, meaning users can target them with standard CSS selectors:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

Shadow DOM and Style Encapsulation

The styling picture becomes more complex when Shadow DOM is involved. Custom elements using Shadow DOM inherit its key benefits, specifically style encapsulation. Styles defined inside a shadow root stay contained within the host and don't affect or get affected by the outer page. This allows custom elements to set their own default styles.

For a deep dive into Shadow DOM styling, resources such as "A Guide to Styling Elements" on Polymer's documentation and the "Shadow DOM 201: CSS & Styling" guide are valuable references.

Preventing the Flash of Unstyled Content

To mitigate the flash of unstyled content (FOUC), the spec introduces a new CSS pseudo-class: :unresolved. This pseudo-class targets elements that are not yet upgraded. It matches an element from the moment it's in the DOM until the browser triggers its createdCallback(). Once that lifecycle callback fires, the upgrade process completes and the element leaves the unresolved state.

For example, you can fade in an x-foo tag once it's registered:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

Remember that :unresolved only works on elements waiting for their definition. It does not apply to elements inheriting from HTMLUnknownElement, as detailed in the upgrade process section.

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

Browser Support and a Look Back

Detecting Support

Feature detection is straightforward: just verify the existence of document.registerElement():

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

Current Support Status

document.registerElement() first appeared behind a flag in Chrome 27 and Firefox around version 23. The spec has since evolved, and Chrome 31 was the first to offer true support for the updated version. For browsers without native support, a polyfill exists, used by both Google's Polymer and Mozilla's X-Tag.

The Fate of HTMLElementElement

Those who followed the early standardization know about the now-defunct <element> tag. It was intended to allow declarative element registration:

<element name="my-element">
    ...
</element>

However, the approach ran into insurmountable timing problems with the upgrade process, among other corner cases. Ultimately, it was removed from the spec; Dimitri Glazkov announced this decision on public-webapps in August 2013.

Despite its removal from the standard, the concept lives on. Polymer implements a declarative form of registration using <polymer-element>, which works by registering itself with document.registerElement('polymer-element') and applying the techniques used for creating elements from a template.

Taking Stock

Custom elements let us extend HTML’s native vocabulary. When combined with Shadow DOM and the <template> tag, they form the core of Web Components, offering a path to rich, self-contained markup. For those wanting to experiment with these capabilities, the Polymer library remains a practical starting point.