How to define a custom HTML tag in JavaScript

How to define a custom HTML tag in JavaScript

Custom elements are the secret sauce behind Web Components, which will allow you to create your own HTML tags with custom behavior. Think of them as tiny, reusable widgets that encapsulate structure, style, and behavior all in one neat package. You define what your element looks like and how it should behave, then use it like any built-in tag—only cooler.

At its core, a custom element is a class that extends HTMLElement (or another element type). This class hooks into a lifecycle controlled by the browser, triggering specific methods whenever your element is created, inserted into the DOM, removed, or updated. These lifecycle callbacks let you manage initialization, teardown, and updates cleanly.

The magic method names you’ll hear about are connectedCallback, disconnectedCallback, attributeChangedCallback, and adoptedCallback. Each one maps to a stage in the element’s life: when it appears, disappears, gets its attributes changed, or is moved between documents.

Custom elements aren’t just about behavior – they also bring modularity. You can package styles inside the component’s shadow DOM, ensuring they don’t leak out or get overwritten by other page styles. This scoped style isolation is what makes building complex UI manageable without the risk of CSS conflicts.

Before diving into registering and using these elements, you need to remember one rule: your custom tag names must contain a hyphen. This prevents clashes with future standard HTML tags and lets the browser know that is something custom.

Here’s the barebones class you’ll start with:

class MyElement extends HTMLElement {
  constructor() {
    super();
    // setup code here
  }
}

That’s all for now, but soon we’ll take this skeleton and breathe life into it with registration and lifecycle callbacks so your component can take meaningful action when tossed onto a page. The real trick isn’t just creating elements—it’s mastering when and how they respond.

Keep in mind that while the class defines the custom element’s essence, you make it available to the DOM using customElements.define—a method that pairs your element’s tag name with its class. Without this call, your new tag is just a pretty worthless name.

So, when you call:

customElements.define('my-element', MyElement);

you’ve just told the browser, “Hey, whenever you see in the markup, instantiate this class and treat it like any other HTML tag.” Suddenly, your custom element is live and interactive.

This tight integration with native DOM means you’re not hacking around—it’s a first-class citizen in your web app. They can receive attributes, dispatch events, even work with forms, just like built-ins. Soon you’ll want to handle those lifecycle callbacks so your elements behave exactly when they appear, disappear, or update.

Speaking of building up from here, let’s get down to registering your first custom tag—you’ll see how simpler it really is once you write a few lines:

registering your first custom HTML tag

Start by defining a class that extends HTMLElement, just like before, but this time add some actual behavior:

class CoolButton extends HTMLElement {
  constructor() {
    super();
    this.addEventListener('click', () => {
      alert('You clicked me!');
    });
  }
}

Next, register it using customElements.define. Remember, the tag name must contain a hyphen to avoid conflicts:

customElements.define('cool-button', CoolButton);

Now you can drop this tag into your HTML:

<cool-button>Click me!</cool-button>

Instant interactivity. Notice how simpler this is—no jQuery, no event delegation hacks. The element handles its own events inside its class.

Want to get a bit fancy and attach a shadow DOM to encapsulate styles and markup?

class ShadowButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = 
      &lt;style&gt;
        button {
          background-color: #007bff;
          border: none;
          color: white;
          padding: 10px 20px;
          cursor: pointer;
          font-size: 16px;
        }
        button:hover {
          background-color: #0056b3;
        }
      &lt;/style&gt;
      &lt;button&gt;Shadow Click&lt;/button&gt;
    ;
    shadow.querySelector('button').addEventListener('click', () => {
      alert('Shadow DOM button clicked!');
    });
  }
}
customElements.define('shadow-button', ShadowButton);

This example packs structure, style, and behavior inside the shadow root. The button is styled independently of the global page styles, meaning you don’t have to worry about CSS bleeding in or out.

If you want to customize that button’s label via an attribute, just poke at this.getAttribute in the constructor. But to react to changes dynamically, you’ll want to implement lifecycle callbacks, which we’ll cover next. For now, here’s the basic way to read an attribute in the constructor:

class LabelButton extends HTMLElement {
  constructor() {
    super();
    const label = this.getAttribute('label') || 'Default Label';
    this.innerHTML = &lt;button&gt;${label}&lt;/button&gt;;
  }
}
customElements.define('label-button', LabelButton);

Used like this:

<label-button label="Press me"></label-button>

The browser creates the element, runs your constructor, and the button reflects that label. But, again, if you change the attribute after insertion, nothing updates. That’s where attributeChangedCallback comes into play, and we’ll get there.

Before that, you should be aware that customElements.define throws if you try to register a tag name twice or if your class doesn’t extend the right HTMLElement. That is by design—don’t silently let the definition fail. If you’re dynamically loading scripts or working in a hot module replacement environment, make sure to guard or reload carefully.

With your newly minted tag registered, open your browser console and drop the tag directly into the DOM via JavaScript for quick experiments:

const btn = document.createElement('cool-button');
btn.textContent = 'Dynamic Button';
document.body.appendChild(btn);

The element behaves identically whether it is statically declared in HTML or thrown in at runtime. That’s the beauty of custom elements—they form a consistent abstraction layer on top of the DOM.

Once you start juggling attributes, reacting to state changes, or managing child nodes, you’ll want more lifecycle hooks, but this foundational registration is all you need to get started creating powerful, reusable components that plug into your web pages like native elements. The next step is mastering those lifecycle methods so your custom tags aren’t just static widgets, but responsive, evolving pieces of your app’s UI fabric.

handling lifecycle callbacks like a pro

Now that you’ve got the basics of registering your custom elements down, let’s dive into lifecycle callbacks. These methods are your gateway to responding to changes in your element’s state and environment. They allow you to hook into significant moments in your element’s life cycle, giving you the power to perform actions at just the right time.

The first callback you’ll encounter is connectedCallback. This method is called every time your element is inserted into the DOM. It’s the perfect place to perform tasks like fetching data, setting up event listeners, or initializing UI elements. Here’s how you might use it:

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

  connectedCallback() {
    this.shadowRoot.innerHTML = '<div>Component Loaded!</div>';
    console.log('Element added to page.');
  }
}
customElements.define('my-component', MyComponent);

In this example, when is inserted into the DOM, it first displays “Loading…” and then updates to “Component Loaded!” once the element is connected. That’s a simple yet effective way to manage dynamic content based on the element’s presence in the DOM.

Next, we have disconnectedCallback, which is invoked when your element is removed from the DOM. That is where you can clean up resources, like removing event listeners or stopping any ongoing processes. Here’s a quick example:

class MyCleanupComponent extends HTMLElement {
  constructor() {
    super();
    this.handleClick = this.handleClick.bind(this);
  }

  connectedCallback() {
    this.addEventListener('click', this.handleClick);
  }

  disconnectedCallback() {
    this.removeEventListener('click', this.handleClick);
    console.log('Element removed from page. Clean up done.');
  }

  handleClick() {
    console.log('Element clicked!');
  }
}
customElements.define('my-cleanup-component', MyCleanupComponent);

In this scenario, when the element is clicked, it logs a message. When it’s removed from the DOM, it cleans up by removing the event listener, preventing memory leaks and ensuring that your component behaves well even after being removed.

Then we have attributeChangedCallback, which is called whenever an observed attribute changes. This callback especially important for making your element dynamic, reacting to changes in its attributes. To use this callback, you also need to specify which attributes you want to observe:

class MyAttributeComponent extends HTMLElement {
  static get observedAttributes() {
    return ['label'];
  }

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.updateLabel();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'label') {
      this.updateLabel();
    }
  }

  updateLabel() {
    this.shadow.innerHTML = &lt;div&gt;${this.getAttribute('label') || 'Default Label'}&lt;/div&gt;;
  }
}
customElements.define('my-attribute-component', MyAttributeComponent);

In this example, the component updates its displayed label whenever the label attribute changes. This ensures that your component stays in sync with the state of its attributes, providing a responsive user experience.

Lastly, there’s adoptedCallback, which is called when your custom element is moved to a new document. That is less common but is useful in certain scenarios, especially when dealing with iframes or service workers. You would use it similarly to the other callbacks to handle any necessary adjustments to your element when it changes context.

With these lifecycle callbacks in your toolkit, you can create components that are not only interactive but also smart and efficient. By using these methods, you can ensure that your custom elements respond appropriately to their environment, maintain clean code, and enhance user experience.

Comments

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

Leave a Reply

Your email address will not be published. Required fields are marked *