Custom Elements: Defining New HTML Tags

Custom Elements is a web platform API that lets developers create new HTML tags or extend existing ones using standard JavaScript, HTML, and CSS. It forms the foundation of the web components model, giving developers a browser-native way to build reusable components with less code and more modularity in their applications.

The customElements global object is the entry point for defining a custom element. Calling customElements.define() with a tag name and a JavaScript class that extends the base HTMLElement teaches the browser about a new tag.

// Create a class for the element
class AppDrawer extends HTMLElement {
  constructor() {
    super();
    // ...
  }
}

// Register the custom element
customElements.define('app-drawer', AppDrawer);

Once registered, a custom element is used exactly like any standard HTML element. It can be declared in a page, created dynamically with JavaScript, and have event listeners attached to it.

Extending HTMLElement ensures the custom element inherits the full DOM API. Any properties or methods you add to the class become part of the element's DOM interface, allowing you to define a public JavaScript API for your tag. Inside a class definition, the this keyword refers to the DOM element instance itself, giving you direct access to its properties, children, and methods.

class AppDrawer extends HTMLElement {
  constructor() {
    super();
    this.disabled = false;
    this.addEventListener('click', () => this.toggleDrawer());
  }

  get open() {
    return this.hasAttribute('open');
  }

  set open(val) {
    if (val) {
      this.setAttribute('open', '');
    } else {
      this.removeAttribute('open');
    }
    this.toggleDrawer();
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  toggleDrawer() {
    // ...
  }
}

Naming Rules and Lifecycle Hooks

Custom element names must contain a dash (-) so the HTML parser can distinguish them from regular elements and ensure forward compatibility with future HTML tags. Valid names include <x-tags>, <my-element>, and <my-awesome-app>; invalid examples are <tabs> and <foo_bar>. A tag can only be registered once—a second attempt throws a DOMException. Since HTML only permits self-closing tags for void elements, custom elements always need a closing tag.

Custom element reactions are synchronous lifecycle callbacks that run during key moments of an element's existence:

Reaction Called when
constructor An instance of the element is created (created or upgraded).
connectedCallback The element is inserted into the DOM.
disconnectedCallback The element is removed from the DOM.
attributeChangedCallback A defined attribute is added, removed, or changed.

These callbacks fire immediately: calling el.setAttribute() synchronously triggers attributeChangedCallback(), and removing an element from the DOM right after triggers disconnectedCallback(). Use them to set up or clean up resources, such as opening a connection to IndexedDB in connectedCallback() and closing it in disconnectedCallback(). Note that you cannot rely on the disconnect callback firing in every scenario—it will never be called if the user closes the tab.

Properties, Attributes, and Reflection

HTML properties commonly reflect their value back to the DOM as an attribute. For instance, changing hidden or id in JavaScript updates the live DOM markup. This reflection keeps a component's DOM representation in sync with its JavaScript state, which matters for scenarios like user-defined styles that rely on attribute selectors.

// In JS
drawer.disabled = true;

// Reflects to the DOM
<app-drawer disabled></app-drawer>

To support this pattern, define getters and setters that synchronize a property with an attribute of the same name. You can also observe attribute changes by listing them in a observedAttributes static getter and implementing attributeChangedCallback(). The browser invokes this callback for every change to the listed attributes, allowing the element to react to external state changes.

class AppDrawer extends HTMLElement {
  static get observedAttributes() {
    return ['disabled', 'open'];
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  // Fire whenever the disabled attribute is added, removed, or changed
  attributeChangedCallback(name, oldValue, newValue) {
    if (this.disabled) {
      // ...
    }
  }
}

Progressive Enhancement

Custom elements can be used on a page before their definitions are registered. The browser treats unknown tags differently until they are defined, and the process of endowing an existing element with its class is called element upgrade. This enables progressive enhancement: you can declare <app-drawer> elements early and register them with customElements.define() later.

Use window.customElements.whenDefined() to know when a tag becomes available. This method returns a Promise that resolves once the element is registered, which is useful for delaying work until a set of child elements have all been upgraded.

Promise.all([
  customElements.whenDefined('app-drawer'),
  customElements.whenDefined('x-foo')
]).then(() => {
  // All custom elements are defined
  console.log('All upgraded!');
});

Managing Element Content

Custom elements can manage their own content by using DOM APIs within element code, leveraging lifecycle reactions.

Shadow DOM allows an element to own, render, and style a chunk of DOM that is isolated from the rest of the page. Inside your element's constructor, call this.attachShadow to set up a shadow root. Combine Shadow DOM with the <template> element to declare a fragment of HTML that stays inert at page load and becomes active at runtime—an ideal placeholder for defining a custom element's internal structure.

customElements.define('x-foo-from-template', class extends HTMLElement {
  constructor() {
    super();
    const template = document.getElementById('x-foo-from-template');
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(template.content.cloneNode(true));
  }
});

With this approach, the custom element's Shadow DOM is built from the template, its internal DOM remains scoped to the element, and any CSS inside the shadow root stays local to it.

Styling Custom Elements

Even when an element defines its own styles inside Shadow DOM, users can override them from the page with their own CSS. In terms of specificity, styles from the page take precedence over element-defined styling.

app-drawer:not(:defined) {
  display: none;
}

Before an element is upgraded, you can target it with the :defined pseudo-class. This is helpful for pre-styling—for example, hiding undefined components to prevent a flash of unstyled content (FOUC), then fading them in as they become defined. After the element is registered, a selector like app-drawer:not(:defined) stops matching entirely.

Creating a customized built-in element

Beyond defining brand-new tags, the Custom Elements API also lets you extend existing elements—both other custom elements and the browser's native HTML tags. This approach keeps all the original element's functionality, including DOM properties, methods, and built-in accessibility.

To extend a custom element, your class simply inherits from that element's class:

class FancyDrawer extends AppDrawer {
  constructor() {
    super(); // always call super() first in the constructor. This also calls the extended class' constructor.
    // ...
  }

  toggleDrawer() {
    // Possibly different toggle implementation?
    // Use ES2015 if you need to call the parent method.
    // super.toggleDrawer()
  }

  anotherMethod() {
    // ...
  }
}

customElements.define('fancy-app-drawer', FancyDrawer);

Extending a native element is a more interesting case. Instead of duplicating the logic and features of a standard tag like <button>, you can progressively enhance the existing element. The primary benefit is gaining all of its pre-existing DOM properties, methods, and accessibility support out of the box.

When extending a native element, your class inherits from the appropriate DOM interface rather than HTMLElement directly. For instance, an enhanced <button> should extend HTMLButtonElement, and a custom <img> should extend HTMLImageElement:

// See https://html.spec.whatwg.org/multipage/indices.html#element-interfaces
// for the list of other DOM interfaces.
class FancyButton extends HTMLButtonElement {
  constructor() {
    super(); // always call super() first in the constructor.
    this.addEventListener('click', e => this.drawRipple(e.offsetX, e.offsetY));
  }

  // Material design ripple animation.
  drawRipple(x, y) {
    let div = document.createElement('div');
    div.classList.add('ripple');
    this.appendChild(div);
    div.style.top = `${y - div.clientHeight/2}px`;
    div.style.left = `${x - div.clientWidth/2}px`;
    div.style.backgroundColor = 'currentColor';
    div.classList.add('run');
    div.addEventListener('transitionend', (e) => div.remove());
  }
}

customElements.define('fancy-button', FancyButton, {extends: 'button'});

Notice that the define() call includes a third argument. This required parameter tells the browser which native tag you're extending. That disambiguation is necessary because many HTML tags share the same DOM interface—<section>, <address>, and <em> all map to HTMLElement, while <q> and <blockquote> both use HTMLQuoteElement. The {extends: 'blockquote'} option lets the browser know you're enhancing a <blockquote> and not a <q>. A full mapping of HTML elements to interfaces is available in the HTML spec.

Consumers can use a customized built-in element in a few different ways. They can declare it declaratively by adding an is="" attribute to the native tag:

<!-- This <button> is a fancy button. -->
<button is="fancy-button" disabled>Fancy button!</button>

They can also create an instance in JavaScript:

// Custom elements overload createElement() to support the is="" attribute.
let button = document.createElement('button', {is: 'fancy-button'});
button.textContent = 'Fancy button!';
button.disabled = true;
document.body.appendChild(button);

Or use the new operator directly:

let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;

As another example, here's an element that extends <img>:

customElements.define('bigger-img', class extends Image {
  // Give img default size if users don't specify.
  constructor(width=50, height=50) {
    super(width * 10, height * 10);
  }
}, {extends: 'img'});

Users could then include it in markup like so:

<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">

Or instantiate it in JavaScript:

const BiggerImage = customElements.get('bigger-img');
const image = new BiggerImage(15, 20); // pass constructor values like so.
console.assert(image.width === 150);
console.assert(image.height === 200);

Unknown elements vs. undefined custom elements

HTML is famously lenient. If you declare a tag like <randomtagthatdoesntexist>, the browser accepts it without complaint because the HTML specification says that any unrecognized tag is parsed as an HTMLUnknownElement.

Custom elements behave differently. A potential custom element—one with a valid name containing a hyphen—is parsed as an HTMLElement instead. You can verify this in any browser with custom elements support by opening the console (Ctrl+Shift+J or Cmd+Opt+J on Mac) and pasting in:

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

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

Working with the customElements global

The customElements namespace provides several methods for managing custom elements.

define(tagName, constructor, options) registers a new custom element in the browser:

customElements.define('my-app', class extends HTMLElement { ... });
customElements.define(
    'fancy-button', class extends HTMLButtonElement { ... }, {extends: 'button'});

get(tagName) returns the constructor for a registered element. It returns undefined when no definition exists:

let Drawer = customElements.get('app-drawer');
let drawer = new Drawer();

whenDefined(tagName) returns a Promise that resolves when the element is defined. If it's already registered, the Promise resolves immediately; it rejects when given an invalid custom element name:

customElements.whenDefined('app-drawer').then(() => {
  console.log('ready!');
});

Support and history

Custom Elements isn't a new idea. Chrome 36+ shipped an earlier iteration based on document.registerElement(), now retroactively named v0. That approach is deprecated in favor of customElements.define(), which is the v1 standard currently being implemented by browser vendors.

Today, Custom Elements v1 is supported in Chrome 54 (status), Safari 10.1 (status), and Firefox 63 (status). Microsoft has begun work on Edge.

You can feature-detect support by checking for window.customElements:

const supportsCustomElementsV1 = 'customElements' in window;

For browsers that lack native support, a standalone polyfill is available. The recommended path, however, is to use the webcomponents.js loader, which performs its own feature detection and asynchronously loads only the polyfills a particular browser actually needs:

npm install --save @webcomponents/webcomponentsjs
<!-- Use the custom element on the page. -->
<my-element></my-element>

<!-- Load polyfills; note that "loader" will load these async -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js" defer></script>

<!-- Load a custom element definitions in `waitFor` and return a promise -->
<script type="module">
  function loadScript(src) {
    return new Promise(function(resolve, reject) {
      const script = document.createElement('script');
      script.src = src;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }

  WebComponents.waitFor(() => {
    // At this point we are guaranteed that all required polyfills have
    // loaded, and can use web components APIs.
    // Next, load element definitions that call `customElements.define`.
    // Note: returning a promise causes the custom elements
    // polyfill to wait until all definitions are loaded and then upgrade
    // the document in one batch, for better performance.
    return loadScript('my-element.js');
  });
</script>

A new tool for the platform

Custom elements provide a standardized way to define and extend HTML tags, enabling truly reusable components without any framework. Combined with other platform primitives like Shadow DOM, <template>, and CSS custom properties, they round out the Web Components picture:

  • A cross-browser web standard for creating and extending reusable markup.
  • No library framework required—vanilla JS and HTML work just fine.
  • A familiar model built on standard DOM, CSS, and HTML concepts.
  • Interoperable with other emerging web platform features.
  • Well-integrated with browser developer tools.
  • Native access to existing accessibility hooks.