How to register a web component using customElements.define

How to register a web component using customElements.define

A web component is essentially a set of standardized APIs that allow you to create reusable, encapsulated HTML elements. The core idea is to extend HTML with new tags defined by you, the developer, that behave like native elements but carry your own custom behavior and styling.

At the heart of any web component lies the Custom Elements specification. This allows you to define a new element by creating a class that extends HTMLElement or one of its subclasses. This class governs the lifecycle of the element, from creation to attachment, to attribute changes, and eventual removal.

Another critical piece is the Shadow DOM. This is what enables true encapsulation in web components. When you attach a shadow root to your element, you create a separate DOM tree that lives “inside” the element but is hidden from the main document’s DOM. Styles and scripts inside this shadow tree don’t leak out, nor do external styles bleed in, unless explicitly allowed.

Besides these, the HTML Templates API often plays a supportive role. Templates allow you to define chunks of markup that are inert until instantiated. This pattern helps separate the static markup from the dynamic logic you write in JavaScript, making your component’s internal structure cleaner and easier to maintain.

To summarize the anatomy, a web component generally consists of:

  • A class extending HTMLElement, handling lifecycle callbacks like connectedCallback, disconnectedCallback, attributeChangedCallback, and optionally adoptedCallback.
  • An attached shadow root, often in ‘open’ mode to allow script access, which holds the component’s internal DOM.
  • Internal styling scoped to the shadow DOM, usually via tags inside the shadow root.
  • Optional templates to define reusable markup structures.

Consider this minimal example just to illustrate the anatomy in raw form:

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = 
      <style>
        p { color: blue; }
      </style>
      <p>Hello from shadow DOM</p>
    ;
  }
}

Here, the constructor sets up the shadow root and injects some markup with scoped styles. The element itself remains a simple HTMLElement but gains a whole new internal world that’s isolated from the rest of the page.

Lifecycle callbacks are a key part of the anatomy. The connectedCallback fires when the element is appended to the document, allowing setup that depends on DOM presence. disconnectedCallback is your cleanup hook when the element is removed. attributeChangedCallback listens for changes to specific attributes you declare observed, so the component can react accordingly.

Attributes, properties, and events form the communication channels of your component. Attributes come from HTML and are always strings, while properties live in JavaScript and can be any type. Keeping these in sync is often a source of subtle bugs, so many developers create getter/setter pairs on the custom element class to bridge attributes and properties cleanly.

The shadow DOM also restricts event bubbling in an interesting way: events originating inside the shadow root can be retargeted, meaning the event’s target seen outside the component is the custom element itself, not the internal node. This preserves encapsulation but requires awareness when wiring event handlers outside the component.

Finally, style encapsulation is a major benefit. Because the shadow DOM creates a boundary, CSS selectors outside the component cannot pierce inside, and styles inside cannot leak out. This means your component’s look is preserved regardless of page-wide CSS, avoiding conflicts and the need for complex naming conventions.

Understanding these pieces—the custom element class, shadow DOM, lifecycle callbacks, attribute-property synchronization, and styling—is essential before diving into creating your own components. They form the skeleton on which powerful, reusable, and maintainable UI elements are built. Once you grasp this anatomy, the next step is to build a simple custom element from scratch to see these concepts in action.

Creating a custom element class step by step

Begin by defining a class that extends HTMLElement. This class will represent your custom element and encapsulate its behavior. Inside the constructor, always call super() first to ensure the base class initializes properly.

Next, attach a shadow root to your element. That’s done via this.attachShadow({ mode: 'open' }). Using mode: 'open' allows external scripts to access the shadow DOM via the shadowRoot property, which is useful during development and testing.

Once the shadow root is attached, you can populate it with HTML and CSS. One simpler way is to assign a template literal to this.shadowRoot.innerHTML. Keep in mind that all styles defined here are scoped to the shadow DOM, so they won’t affect the main document or other components.

Here is a basic example illustrating these steps:

class SimpleGreeting extends HTMLElement {
  constructor() {
    super(); // Always call super() first in constructor
    this.attachShadow({ mode: 'open' }); // Attach shadow root

    this.shadowRoot.innerHTML = 
      <style>
        p {
          font-family: Arial, sans-serif;
          color: darkgreen;
          font-size: 1.2em;
        }
      </style>
      <p>Hello, Web Component!</p>
    ;
  }
}

At this point, the element has a shadow DOM with a styled paragraph inside. However, the component is not yet registered with the browser, so it cannot be used in HTML. Before that, consider adding lifecycle callbacks such as connectedCallback to perform actions when the element is inserted into the document.

For example, you might want to log a message or initialize state when the element connects:

class SimpleGreeting extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = 
      <style>
        p {
          font-family: Arial, sans-serif;
          color: darkgreen;
          font-size: 1.2em;
        }
      </style>
      <p>Hello, Web Component!</p>
    ;
  }

  connectedCallback() {
    console.log('SimpleGreeting element added to page.');
  }

  disconnectedCallback() {
    console.log('SimpleGreeting element removed from page.');
  }
}

Adding these callbacks lets you hook into the element’s lifecycle, which very important for managing resources, event listeners, or starting animations.

Another important aspect is reacting to attribute changes. To do this, define a static getter called observedAttributes that returns an array of attribute names to watch. Then implement attributeChangedCallback(name, oldValue, newValue) to respond to updates.

This pattern is essential for synchronizing attributes and internal state. For example, if you want your greeting to be customizable, you could add a name attribute and update the displayed text accordingly:

class SimpleGreeting extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = 
      <style>
        p {
          font-family: Arial, sans-serif;
          color: darkgreen;
          font-size: 1.2em;
        }
      </style>
      <p>Hello, stranger!</p>
    ;
  }

  static get observedAttributes() {
    return ['name'];
  }

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

  connectedCallback() {
    if (this.hasAttribute('name')) {
      this._updateGreeting(this.getAttribute('name'));
    }
  }

  _updateGreeting(name) {
    const p = this.shadowRoot.querySelector('p');
    p.textContent = Hello, ${name}!;
  }
}

Here, whenever the name attribute changes, the attributeChangedCallback fires and updates the paragraph text inside the shadow DOM. The connectedCallback also ensures the greeting is correct when the element is first added to the page.

To make this component easier to use, it’s common to define property accessors that mirror attributes. This lets developers interact with the element via JavaScript properties, improving ergonomics and type safety:

class SimpleGreeting extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = 
      <style>
        p {
          font-family: Arial, sans-serif;
          color: darkgreen;
          font-size: 1.2em;
        }
      </style>
      <p>Hello, stranger!</p>
    ;
  }

  static get observedAttributes() {
    return ['name'];
  }

  get name() {
    return this.getAttribute('name');
  }

  set name(value) {
    if (value) {
      this.setAttribute('name', value);
    } else {
      this.removeAttribute('name');
    }
  }

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

  connectedCallback() {
    if (this.hasAttribute('name')) {
      this._updateGreeting(this.getAttribute('name'));
    }
  }

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

With this, the name property and attribute stay in sync. You can now write:

const greeting = document.querySelector('simple-greeting');
greeting.name = 'Alice';

and the element updates accordingly, reflecting the change in the UI.

In this way, building a custom element class involves a clear sequence: subclass HTMLElement, attach a shadow root, populate it with markup and styles, implement lifecycle callbacks, observe attributes, and synchronize properties. These steps form the foundation upon which you can layer additional functionality, events, and complex interactions.

Next, once your class is ready, the final step before you can use your component in HTML is to register it with the browser’s custom element registry—

Registering and using your web component in the browser

which is accomplished using the customElements.define() method. This method takes two arguments: the name of your custom element, which must contain a hyphen (to distinguish it from built-in elements), and the class you created.

For instance, to register our SimpleGreeting class as a custom element named simple-greeting, you would write:

customElements.define('simple-greeting', SimpleGreeting);

Once registered, you can use your custom element in HTML just like any other element. This means you can include it directly in your markup, and the browser will instantiate an instance of your class whenever it encounters the tag.

Here’s how you might use it in an HTML document:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Web Component</title>
  <script type="module">
    import './simple-greeting.js';
  </script>
</head>
<body>
  <simple-greeting name="Alice"></simple-greeting>
</body>
</html>

In this example, the name attribute is set to “Alice,” which means the component will display “Hello, Alice!” when rendered. The browser takes care of instantiating the SimpleGreeting class and attaching its shadow DOM based on your definition.

To ensure everything functions as expected, you can also test the component in various browsers. Modern browsers support custom elements, but it’s always wise to check compatibility, especially with older versions.

Additionally, if you want to ensure that your component can be reused across different contexts, consider adding default values for attributes and handling undefined states gracefully. This enhances usability and robustness.

When you want to update attributes dynamically, you can simply set the attribute again in JavaScript, and the attributeChangedCallback will manage the updates as previously defined. This seamless interaction between HTML and JavaScript is one of the powerful features of web components.

Furthermore, you can create more complex components by combining multiple custom elements. By composing components together, you can build intricate user interfaces that remain maintainable and encapsulated.

As you build more components, remember to keep the principles of encapsulation and reusability in mind. Each component should do one thing well and communicate clearly with other components, whether through properties, events, or attributes.

This approach not only fosters cleaner code but also enhances the overall architecture of your web applications, allowing for easier updates and the ability to scale your UI as needed.

With the fundamentals of creating, registering, and using your web component well understood, you’re now equipped to explore more advanced topics, such as event handling, custom properties, and integrating with frameworks.

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 *