Introducing Custom Elements

A little over a year ago, we announced the introduction of slot-based shadow DOM API, a lightweight mechanism to encapsulate a DOM tree by allowing the creation of a parallel DOM tree on an element called a “shadow tree” that replaces the rendering of the element without modifying the regular DOM tree.

Today, we’re happy to announce the addition of the Custom Elements API to WebKit. With this API, authors can create usable components by defining their own HTML elements without relying on a JS framework.

Defining a Custom Element

To define a custom element, simply invoke customElements.define with a new local name of the element and a subclass of HTMLElement. Let’s say we’re creating a custom progress bar named custom-progress-bar then one might define the element as follows:

class CustomProgressBar extends HTMLElement {
  constructor() {
      super();
      const shadowRoot = this.attachShadow({mode: 'closed'});
      shadowRoot.innerHTML = `
          <style>
              :host { display: inline-block; width: 5rem; height: 1rem; }
              .progress { display: inline-block; position: relative; border: solid 1px #000; padding: 1px; width: 100%; height: 100%; }
              .progress > .bar { background: #9cf; height: 100%; }
              .progress > .label { position: absolute; top: 0; left: 0; width: 100%;
                  text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
          </style>
          <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
              <div class="bar" style="width: 0px;"></div>
              <div class="label">0%</div>
          </div>
      `;
      this._progressElement = shadowRoot.querySelector('.progress');
      this._label = shadowRoot.querySelector('.label');
      this._bar = shadowRoot.querySelector('.bar');
  }

  get progress() { return this._progressElement.getAttribute('aria-valuenow'); }
  set progress(newPercentage) {
      this._progressElement.setAttribute('aria-valuenow', newPercentage);
      this._label.textContent = newPercentage + '%';
      this._bar.style.width = newPercentage + '%';
  }
};
customElements.define('custom-progress-bar', CustomProgressBar);

We can now instantiate this element in the markup as <custom-progress-bar></custom-progress-bar> or instantiate dynamically as new CustomProgressBar or document.createElement('custom-progress-bar'), and update its progress by element.progress = 50 for example:

progress-bar

See the live demo. While I used ES6 class syntax above, we can write a custom element using a ES5 style constructor as follows:

function CustomProgressBar() {
  const instance = Reflect.construct(HTMLElement, [], CustomProgressBar);
  ...
  return instance;
}
customElements.define('custom-progress-bar', CustomProgressBar);

There are a few restrictions on the first argument of customElements.define:

  • It must start with a lowercase letter a-z.
  • It must not contain a uppercase letter A-Z.
  • It must contain “-“.

See the HTML specification for the precise definition of valid Custom Element names.

Using Custom Elements Callbacks

Many of built-in elements communicate and receive numeral values in their attributes, and respond to the changes in the values. With custom element’s reaction callbacks, we can do the same with custom elements. If we wanted to make our custom progress bar element set the progress by data-progress attribute, for example, we can do:

class CustomProgressBar extends HTMLElement {
  ...
  static get observedAttributes() { return ['value']; }
  attributeChangedCallback(name, oldValue, newValue, namespaceURI) {
      if (name === 'value') {
          const newPercentage = newValue === null ? 0 : parseInt(newValue);
          this._progressElement.setAttribute('aria-valuenow', newPercentage);
          this._label.textContent = newPercentage + '%';
          this._bar.style.width = newPercentage + '%';
      }
  }
  get progress() { return this.getAttribute('value'); }
  set progress(newValue) { this.setAttribute('value', newValue); }
}
<custom-progress-bar value="10"></custom-progress-bar>

Here, we’ve declared that this custom element observes the value attribute in observedAttributes. When the attribute is added, removed, or otherwise mutated, attributeChangedCallback is called by the browser engine. Note that when the attribute is removed, newValue is null. Similarly, when the attribute was newly added, oldValue is null.

The Custom elements API provide a few other types of callbacks for convenience:

  • connectedCallback() – Called when the custom element is inserted into a document.
  • disconnectedCallback() – Called when the custom element is removed from a document.
  • adoptedCallback(oldDocument, newDocument) – Called when the custom element is adopted from an old document to a new document.

One nice characteristic of custom elements reactions is that they’re almost synchronous unlike MutationObserver which delivers its record at the end of the current microtask. When we invoke methods like appendChild and setAttribute, the browser engine immediately invokes all necessary custom elements reactions before returning to the call site. This allows custom elements to mimic the semantics of builtin elements more easily since custom elements have a chance to run and respond to DOM mutations by the time we return to the caller of a DOM API which initiated the DOM mutations.

They’re, however, not synchronous in the sense that all callbacks are invoked only after all DOM mutations have been made. For example, Range’s deleteContents() may delete more than one custom element from the document but `disconnectedCallbacks on those custom elements won’t be invoked until all those removals have happened.

Asynchronously Defining Custom Elements

While we highly recommend using custom elements only after defining those elements by customElements.define, there are a few cases in which asynchronously loading scripts that define custom elements may become handy. The Custom elements API supports this scenario by the way of upgrades. When we instantiate a yet-to-be-defined custom element either in script by document.createElement or in the markup, the browser engine keeps it a plain HTMLElement, and upgrades it to an instance of the custom element later when it is finally defined via customElements.define.

Scripts can wait for a custom element definition to become available by waiting on the promise returned by customElements.whenDefined and retrieve the constructor by customElements.get as in:

customElements.whenDefined('custom-progress-bar').then(function () {
  let CustomProgressBar = customElements.get('custom-progress-bar');
  let instance = new CustomProgressBar;
  ...
});

When an element is upgraded to a custom element, the custom element’s constructor is invoked just like when it’s synchronously constructing a new element but the super() call to the HTMLElement constructor returns the element that’s being upgraded instead of constructing a brand new object. Because the element had already been created and inserted into a document by the time the element is upgraded, such an element can already have attributes and child nodes. When synchronously constructing a custom element, the element returned by the HTMLElement constructor doesn’t have any attributes or child nodes, and it’s still disconnected from the document.

Luckily, we almost never have to worry about this difference when writing a custom element since attributeChangedCallback is automatically invoked on existing observed attributes and connectedCallback is invoked if the upgraded element is already connected to a document when an element is upgraded.

Here’s a little guideline on what to avoid inside a constructor so that we don’t have to suffer any pain points from this difference:

  • Don’t add, remove, mutate, or access any attribute inside a constructor – Attributes don’t even exist during synchronous construction. Use attributeChangedCallback instead. The browser engine will invoke it for each and every attribute when parsing the HTML.
  • Don’t insert, remove, mutate, or access a child – Again, child nodes don’t even exist during synchronous construction. Use child nodes’ connectedCallback and communicate the information upwards.

Conclusion

The Custom Elements API has been implemented and enabled by default in the Safari Technology Preview 18. We’re also in the final stage of identifying and fixing remaining bugs in the Shadow DOM API. We’re truly excited to finally deliver the power of modularization to the Web platform with these two features.