A straightforward introduction to custom elements

[Note: I wrote this post from the point of view of an Elm developer, however aside from a few Elm related notes, it's really just an introduction to custom elements and Shadow DOM. There isn't any Elm code in this post - it's all JavaScript.]

Quite often, you may need to integrate a complex JavaScript UI component into your Elm application, like a rich text editor, or a calendar, or an image editor, or some kind of visualisation. Such components usually have a lot of state, so you might be concerned about getting trapped in the morass of multiple ports and subscriptions and moving state back and forth between Elm and JavaScript.

However, there is an alternative approach: custom elements.

Unfortunately, the documentation for them is less than straightforward, and there is a dearth of tutorials on the topic. There is overlapping terminology (web components, custom elements, Polymer), multiple versions of the specs (should you be using custom elements v0 or v1?) plus all the usual browser compatibility issues (will it work in Edge? What about Firefox?).

Since I decided to demonstrate the use of custom elements in Practical Elm, I went through some confusion and frustration getting all of this stuff straight, and now I'm writing this post which can hopefully clarify things for you as well.

Web Components

First of all, I have to take a step back and talk about Web Components - a term that can be used somewhat interchangeably with custom elements. Web Components are not a single feature but rather a set of four features:

  • Custom HTML elements
  • Shadow DOM
  • HTML imports
  • HTML templates.

Note that this has nothing to do with Google's Polymer project, which is built on top of Web Components. You don't have to use Polymer (unless you want to).

Custom elements are a mechanism for defining new HTML elements (referred to as autonomous custom elements) as well as customising built-in elements. They often rely heavily on the Shadow DOM which allows you to scope HTML and CSS used to build up an element. This is achieved by having special DOM subtrees which are hidden from the regular DOM API functions. A shadow subtree is attached to an element using JavaScript. All the HTML and styles that comprise the subtree are scoped to that the subtree. Regular DOM-related methods like document.querySelector don't see any of the shadow DOM subtrees.

There are two versions of the custom elements spec: v0 and v1, but (aside from the case where you are using existing v0-based code for some reason) you can disregard v0 as v1 is either supported or can be polyfilled. Likewise, there is v0 and v1 of Shadow DOM, but you only need to work with v1 unless you're dealing with existing code relying on v0.

HTML imports are a way of importing an HTML document into another HTML document:

<link rel="import" href="custom-element.html">

custom-element.html referred to in this import can contain script tags, HTML templates and styles. HTML imports are only implemented in Google Chrome, and my understanding is that even there, they will eventually be replaced with something else, so I wouldn't recommend using this feature.

Finally, HTML templates are defined inside the <template> tag. It's not rendered in the DOM, but you can get its contents from JavaScript. Inside a template, you can use the <slot> tag to define placeholders for user-supplied content. Additionally, <style> blocks inside a template are scoped to the document fragments created from the template.

Going back to custom elements, they provide a two-way communication mechanism between Elm and JavaScript. We can use properties and attributes to alter their state, and they can produce custom events which we can handle in Elm to change the model, create commands and so on. Thus, they can be a good alternative to ports when it comes to integrating complex JavaScript components into an Elm application.

Custom elements

[Note: I tested JSFiddle examples shown below in Chrome and Safari, but they will not work in Firefox without adding the appropriate polyfills (which I haven't done).]

The essential piece of Web Components for Elm developers is custom elements, so let's see how we can create one for the purpose of integrating a rich text editor into an Elm application.

To define an autonomous custom element, we have to create a class which extends HTMLElement, and then register it with the browser, providing a corresponding HTML tag name:

class RichEditor extends HTMLElement {}
window.customElements.define('rich-editor', RichEditor)

Note that custom element tags have to include a dash, so I'm not allowed to define richeditor, for example.

Now, we can use this new tag in the HTML like any other tag, and we can style it as well:

<style type="text/css">
    rich-editor { 
        border: 2px solid #2f4858; border-radius: 3px; 
        background-color: #f0fff0; 
        display: block; width: 100px; height: 100px; 
        padding: 3px 
    }
</style>
  
<rich-editor>Added via HTML</rich-editor>

We can also add these elements to the DOM using JavaScript just like any other elements:

let editor = document.createElement('rich-editor')
editor.innerHTML = 'Added via JS'
document.body.appendChild(editor)

This is what the result looks like with one "editor" defined in HTML and another added via JavaScript:

Attributes and properties

In order to have a useful editor, we need to be able to set the initial content, and to update content. We can do this with attributes or properties. As you probably know, attributes and properties have significant overlap but are not equivalent (read more here), and it's possible to work with both from Elm, so I'll demonstrate both.

First, let's set the content of the editor using the value attribute instead of hardcoding it:

class RichEditor extends HTMLElement {
    constructor() {
          super()
    
        if (this.hasAttribute("value")) {
            this.innerHTML = this.getAttribute("value")
        }
    }
}

Now we can write <rich-editor value="Added via HTML"></rich-editor>, and the text will be set inside the editor.

It's also possible to react to changes in attribute values. To do that, we need to declare which attributes are "observed" and then handle the changes via attributeChangedCallback:

class RichEditor extends HTMLElement {
    constructor() {
          super()
    
        if (this.hasAttribute("value")) {
            this.innerHTML = this.getAttribute("value")
        }
    }
  
    static get observedAttributes() { 
        return ["value"] 
    }
  
    attributeChangedCallback(attr, oldVal, newVal) {
        if (attr == "value") {
            this.innerHTML = newVal
        }
    }
}

In addition to attributeChangedCallback, there are several other "lifecycle callbacks" available for custom elements.

In order to be able to be able to set the content from JavaScript, we need to deal with properties. We can define a setter for the value property (and I'll throw in a getter because it'll be useful later):

class RichEditor extends HTMLElement {
    constructor() {
          super()
    
        if (this.hasAttribute("value")) {
            this.innerHTML = this.getAttribute("value")
        }
    }
  
    static get observedAttributes() { 
        return ["value"] 
    }
  
    attributeChangedCallback(attr, oldVal, newVal) {
        if (attr == "value") {
            this.innerHTML = newVal
        }
    }

    get value() {
        return this.innerHTML
    } 

    set value(val) {
        this.innerHTML = val
    } 
}

This allows us to set the content by assigning to the property:

let editor = document.createElement('rich-editor')
document.body.appendChild(editor)
editor.value = "Added via JS"

I'm going to sidestep the issue of synchronising attributes and values here.

Generating events

The other side of the coin is that we'll want to know what the user typed into the editor, and use that content in Elm.

To achieve that, we can get our custom element to generate custom events, say whenever the value property is set:

set value(val) {
    this.innerHTML = val
    this.dispatchEvent(new CustomEvent("change"))
} 

While it's inconsequential for this example, when you actually integrate this into Elm, you'll need to set things up in a way that doesn't produce infinite loops like [set value from Elm] -> [trigger change event] -> [redraw in Elm] -> [set value from Elm] -> etc.

When handling the event in Elm, we can retrieve event.target.value to get the contents of the editor (remembering that we defined a getter for the value property).

Another approach would be to include the relevant data in the event object:

set value(val) {
    this.innerHTML = val
    this.dispatchEvent(new CustomEvent("change", {detail: this.innerHTML}))
} 

This data can then be retrieved from event.detail.

Using the Shadow DOM

The Shadow DOM is particularly useful for custom elements because it allows us to scope internal markup and styles to the element, without affecting anything else in the DOM. For example, we might want to add particular markup for the editing controls in our editor, and also style it in some fashion.

To start working with the Shadow DOM, we need to attach a shadow subtree to the shadow host (in our case, we'll attach it to <rich-editor>):

class RichEditor extends HTMLElement {
    constructor() {
          super()
    
        let shadowRoot = this.attachShadow({mode: "open"})
        let content = this.hasAttribute("value") ? this.getAttribute("value") : ""
        shadowRoot.innerHTML = content
    }

    ...

The mode option can be set to "open" or "closed". Normally you'd use "open" which means that outside JavaScript will be able to access the shadow DOM of your element.

Instead of simply putting some text inside the root node, we can create something more elaborate:

class RichEditor extends HTMLElement {
    constructor() {
          super()
    
        let shadowRoot = this.attachShadow({mode: "open"})
        let content = this.hasAttribute("value") ? this.getAttribute("value") : ""
        shadowRoot.innerHTML = `
            <div class="toolbar">
                <strong>B</strong> 
                <em>I</em> 
                <span style="text-decoration: underline">U</span>
            </div>
            <div class="content">${content}</div>
        `
    }

    ...

We also need to modify the functions that get and set content to work with this structure:

    ...

    attributeChangedCallback(attr, oldVal, newVal) {
        if (attr == "value") {
            this.value = newVal
        }
    }

    get value() {
        return this.shadowRoot.querySelector(".content").innerHTML
    } 

    set value(val) {
        this.shadowRoot.querySelector(".content").innerHTML = val
        this.dispatchEvent(new CustomEvent("change", {detail: this.innerHTML}))
    } 

    ...

Note that the shadowRoot property is available to us once the root node is created, and I'm using its querySelector method to find the appropriate div to populate.

Finally, we can improve the styling of our custom element by adding a <style> to the markup:

    shadowRoot.innerHTML = `
        <style>
            :host {
                border: 2px solid #2f4858; border-radius: 3px; 
                background-color: #f0fff0; 
                display: block; 
                padding: 3px; margin: 10px
            }
            .toolbar { height: 20px; border-bottom: 1px solid #2f4858 }
            .content { color: #33658a; padding-top: 3px; }
        </style>
        <div class="toolbar">
            <strong>B</strong> 
            <em>I</em> 
            <span style="text-decoration: underline">U</span>
        </div>
        <div class="content">${content}</div>
    `

Remember that these styles are scoped to the element so there's no need to worry about conflicts with the styles of the enclosing page (and hence we can keep class names simple).

The :host selector allows us to style the custom element from the inside. Note, however, that rules specified outside will override the :host style as they have higher specificity. With that in mind, we can leave some styles to be specified on the outside. For example, let's set the size of the element on the outside:

rich-editor { width: 300px; height: 100px }

And that's it! Hopefully this covers enough of the basics to get you started. I've provided links to further reading below.

The final version of our custom element looks like this:

Adding polyfills for wider browser support

Polyfills are not required for Chrome, but to support other browsers you may need one or more polyfills. These polyfills can be found on GitHub: webcomponents/webcomponentsjs and also on NPM, in the @webcomponents namespace.

Polyfills allow you to support Safari, Firefox, Microsoft Edge, and possibly even Internet Explorer 11 (although you might run into issues with components written using ES6 features or relying on other unsupported features). Keep in mind that polyfills may have a performance penalty, so it's advisable to benchmark custom elements before choosing them over ports.

There are three main ways to add polyfills:

  1. Load the polyfill bundle which includes all the necessary polyfills. Assuming you've installed the bundle with npm install @webcomponents/webcomponentsjs, you can load the bundle like this:

    <script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
    
  2. Use the client-side loader which probes feature support in the browser and loads polyfills accordingly. It's also available in the bundle, so to load it synchronously you'd write:

    <script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
    

    Note that it's also possible to inline the source of the loader into your HTML, or to use defer to load polyfills asynchronously.

  3. If you're only interested in polyfills for custom elements and/or Shadow DOM, you can get them separately from NPM (@webcomponents/custom-elements and @webcomponents/shadydom) or GitHub (webcomponents/custom-elements and webcomponents/shadydom).

Read more about using polyfills.

Integrating custom elements into an Elm application

This post has been about creating custom elements. In Practical Elm, I demonstrate how to integrate a custom element that you or someone else has made into an Elm application, using the well known rich editor called Ace (wrapped in a custom element) as an example.

Next steps

For some hands-on practice, you can:

  • Add an attribute that marks the editor as read-only
  • Use a <template> element to define the markup of the element instead of setting shadowRoot.innerHTML
  • Get the custom element to emit a ready event when the constructor code has executed.

Further watching and reading:

Comments or questions? I'm @alexkorban on Twitter.