How to define a custom HTML tag in JavaScript

How to define a custom HTML tag in JavaScript

Everything you need to create your own HTML element starts with the Custom Elements API. It’s a web standard that lets you define new elements and their behavior by extending HTMLElement or other specialized base classes like HTMLButtonElement.

At the core, you create a class that inherits from HTMLElement. This class can respond to lifecycle callbacks, handle attributes, and manage internal state. The browser then uses this class whenever it encounters your custom element tag in the DOM.

There are a handful of lifecycle callbacks that matter most:

connectedCallback() – Runs when the element is inserted into the DOM. That’s often where you set up event listeners or initial rendering.

disconnectedCallback() – Fires when the element is removed from the DOM, letting you clean up anything set in connectedCallback.

attributeChangedCallback(name, oldValue, newValue) – Called whenever an observed attribute changes. You specify which attributes to watch by adding a static observedAttributes getter on your class.

Here’s how you might start thinking about it:

class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ['some-attr'];
  }

  connectedCallback() {
    // Setup code here, like rendering or event attachment
  }

  disconnectedCallback() {
    // Cleanup code here
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'some-attr') {
      // React to attribute change, update internal state or UI
    }
  }
}

It’s important to remember that just defining the class doesn’t make the browser recognize a new tag yet. The class has to be registered with a unique, hyphenated tag name using customElements.define(). That’s how the browser associates your class with the tag.

Custom elements can be as simple as a toggle button or as complex as a full application shell. You can embed styles, shadow DOM, and even templates inside, but the foundation is always this class with lifecycle hooks and registration.

One wrinkle to watch out for: if you want your custom element to extend built-in elements like or , the process varies slightly. You’ll provide an options object during registration, specifying the extended tag. For example:

class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.style.backgroundColor = 'purple';
  }
}

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

Then in your HTML, usage looks like this:


Note the is="fancy-button" attribute—it’s mandatory for extending built-ins. For autonomous elements (those not extending built-ins), you simply use style tags.

Creating a class for your custom element

Now, to flesh out the custom element’s behavior, populate the lifecycle callbacks with meaningful actions. Suppose you want a simple element that displays a greeting and updates when an attribute changes. You could write:

class GreetingElement extends HTMLElement {
  static get observedAttributes() {
    return ['name'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = <p></p>;
  }

  connectedCallback() {
    this._updateRendering();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'name' && oldValue !== newValue) {
      this._updateRendering();
    }
  }

  _updateRendering() {
    const name = this.getAttribute('name') || 'World';
    this.shadowRoot.querySelector('p').textContent = Hello, ${name}!;
  }
}

This example demonstrates several important techniques. First, the constructor sets up the shadow DOM, encapsulating styles and markup from the rest of the page. By attaching a shadow root and populating it with a simple paragraph, you control both the structure and presentation.

The connectedCallback invokes a private method _updateRendering to render the initial content. This method is also called by attributeChangedCallback, ensuring that changes to the name attribute dynamically update the element’s display.

Using the shadow DOM here is optional but recommended for encapsulation. Without it, your element’s internal HTML might clash with CSS or scripts elsewhere on the page.

In more elaborate components, you might attach event listeners in connectedCallback and remove them in disconnectedCallback to avoid memory leaks:

class ClickCounter extends HTMLElement {
  constructor() {
    super();
    this.count = 0;
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = 
      <button>Clicks: 0</button>
    ;
    this._onClick = this._onClick.bind(this);
  }

  connectedCallback() {
    this.shadowRoot.querySelector('button').addEventListener('click', this._onClick);
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this._onClick);
  }

  _onClick() {
    this.count++;
    this.shadowRoot.querySelector('button').textContent = Clicks: ${this.count};
  }
}

Notice how the event handler is bound to the instance in the constructor, ensuring the correct context when called. By adding and removing the event listener in the lifecycle callbacks, the element remains robust when inserted and removed multiple times.

Registering and using your new HTML tag

After defining your custom element class, the next step is registering it with the browser’s registry using customElements.define(). This method takes two mandatory arguments: the name of the custom tag and the class you created to describe its functionality.

The tag name must include a hyphen to distinguish it from standard HTML elements. This naming constraint ensures no conflicts with future HTML standard tags. For example:

customElements.define('greeting-element', GreetingElement);

Once registered, you can use your custom element anywhere in your HTML, either statically or dynamically via JavaScript. Here’s how you’d use the greeting example in plain HTML:

<greeting-element name="Alice"></greeting-element>

The browser will automatically instantiate an instance of GreetingElement whenever it sees the tag in the DOM. The lifecycle callbacks you implemented will take care of rendering and reacting to attribute changes.

If you prefer adding the custom element dynamically through JavaScript, you can create and insert it like so:

const greeting = document.createElement('greeting-element');
greeting.setAttribute('name', 'Bob');
document.body.appendChild(greeting);

This triggers the connectedCallback, and your element renders immediately with the updated name.

When extending built-in elements, as mentioned previously with FancyButton, the registration call changes slightly with an options object specifying the element it extends. Correct usage in your HTML then involves the is attribute to specify which customized built-in is intended.

Trying to register a tag name that does not contain a hyphen, or that’s already been registered, will throw an error. To avoid registration conflicts, always check if a custom element is already defined:

if (!customElements.get('greeting-element')) {
  customElements.define('greeting-element', GreetingElement);
}

It’s common to wrap your registration code in such checks, especially when working with modular codebases or third-party libraries that might register elements with the same name.

Besides simple attribute changes, the lifecycle callbacks allow you to hook into important phases of the element’s existence—for instance, you might fetch data when the element connects or clear timers when it disconnects.

Consider this example where a custom element fetches and displays JSON data when added to the document:

class JsonFetcher extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = '<pre>Loading...</pre>';
  }

  connectedCallback() {
    fetch(this.getAttribute('url'))
      .then(response => response.json())
      .then(data => {
        this.shadowRoot.querySelector('pre').textContent = JSON.stringify(data, null, 2);
      })
      .catch(error => {
        this.shadowRoot.querySelector('pre').textContent = Error: ${error.message};
      });
  }
}

customElements.define('json-fetcher', JsonFetcher);

Use it like so:

<json-fetcher url="https://api.example.com/data"></json-fetcher>

The browser creates an instance, the connectedCallback fires, and the custom element starts its fetch request autonomously. Once the data arrives—or if an error occurs—it updates its internal markup within the shadow DOM.

Source: https://www.jsfaq.com/how-to-define-a-custom-html-tag-in-javascript/


You might also like this video

Comments

No comments yet. Why don’t you start the discussion?

    Leave a Reply