jsguides

Building Web Components with Custom Elements: A Deep Dive

Building web components starts with Custom Elements, the API that lets you define your own HTML tags with encapsulated behaviour. Part of the Web Components spec, they work alongside Shadow DOM and HTML templates. Browser support has been solid since early 2020.

Two types of web components

Autonomous custom elements extend HTMLElement directly. They don’t inherit from any existing tag:

class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }
  connectedCallback() {
    this.shadowRoot.innerHTML = `<button>Click me</button>`;
  }
}

customElements.define("my-button", MyButton);

Use it: <my-button></my-button> anywhere in your HTML. The autonomous approach creates a brand new tag with no inherited semantics, which means you control everything about how it behaves.

Customized built-in elements extend a specific element type. They inherit the behaviour of that element, so they preserve built-in browser features like link navigation or form participation:

class MagicLink extends HTMLAnchorElement {
  constructor() {
    super();
    this.addEventListener("click", e => {
      e.preventDefault();
      console.log("Magic link clicked!");
    });
  }
}

customElements.define("magic-link", MagicLink, { extends: "a" });

Use it: <a is="magic-link" href="/somewhere">Click</a>. The is attribute tells the browser to use your custom element.

Most developers use autonomous custom elements. Customized built-in elements have trickier browser support and are less common in practice.

Lifecycle Callbacks

Custom Elements give you four lifecycle callbacks:

class StatusIndicator extends HTMLElement {
  // Called when the element is added to the DOM
  connectedCallback() {
    this.render();
  }

  // Called when the element is removed from the DOM
  disconnectedCallback() {
    console.log("Element removed from DOM");
  }

  // Called when an attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "status" && oldValue !== newValue) {
      this.render();
    }
  }

  // Called when the element is moved to a new document
  adoptedCallback(oldDocument, newDocument) {
    console.log("Element moved to new document");
  }

  static get observedAttributes() {
    return ["status"];
  }
}

connectedCallback is the most common. It runs when the element is first inserted and whenever it’s re-added to the DOM. Use it to set up your component’s initial state and structure.

disconnectedCallback is for cleanup: remove event listeners, cancel timers, unregister observers. Keeping the DOM clean prevents memory leaks.

attributeChangedCallback only fires for attributes listed in static get observedAttributes(). Adding this getter is required for the callback to work at all.

adoptedCallback fires when document.adoptedNode() moves the element between documents, which is rare in most applications.

Shadow DOM

Shadow DOM lets you encapsulate markup and styles so they don’t bleed out (or in):

class HelloWorld extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        p { color: steelblue; font-weight: bold; }
      </style>
      <p>Hello, <slot></slot>!</p>
    `;
  }
}

customElements.define("hello-world", HelloWorld);

When you use <hello-world>World</hello-world> in your HTML, the text “World” is projected into the <slot></slot> placeholder inside the shadow DOM. The component’s internal styles apply automatically, scoped to the shadow tree, so the paragraph renders in steelblue without affecting any other <p> elements on the page.

<hello-world>World</hello-world>
<!-- Renders: <p>Hello, World!</p> -->

The <slot> element is a placeholder where text nodes that appear inside your element get projected into the slot.

With mode: "closed" on the shadow root, external JavaScript can’t access element.shadowRoot. Most components use open for debugging convenience.

Observed Attributes

Attributes and properties should stay in sync. The standard pattern:

class ProgressBar extends HTMLElement {
  static get observedAttributes() {
    return ["value", "max"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  get value() {
    return Number(this.getAttribute("value") || 0);
  }

  set value(v) {
    this.setAttribute("value", v);
  }

  render() {
    const percent = (this.value / this.max) * 100;
    this.style.width = `${percent}%`;
  }
}

Setting element.value = 50 and setting <element value="50"> both trigger render(). The getter and setter act as a bridge between the JavaScript property and the HTML attribute, so consumers of your element can use whichever API they prefer and the component stays consistent.

Upgrading element definitions

Custom elements upgrade when the parser encounters them, before your script finishes loading. A placeholder element exists immediately:

<my-widget>Loading...</my-widget>
<script>
  // my-widget exists in DOM even before this runs
  console.log(document.querySelector("my-widget") instanceof HTMLElement); // true
</script>

This means you can render a skeleton UI and fill it in once the class is defined, giving users immediate visual feedback even before the component script finishes loading. The customElements.whenDefined() promise resolves when your element is ready, which is useful for coordinating scripts that depend on your component:

customElements.whenDefined("my-widget").then(() => {
  console.log("my-widget is now defined!");
});

Slots and distributed children

Slots let you project children from the light DOM into specific positions inside your shadow DOM. Named slots give you fine-grained control over where each child lands, while a default slot catches everything else. The example below shows a tab panel component with both named and default slots:

class TabPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; border: 1px solid #ccc; padding: 1em; }
        ::slotted([slot="title"]) { font-weight: bold; font-size: 1.2em; margin-bottom: 0.5em; }
      </style>
      <slot name="title"></slot>
      <slot></slot>
    `;
  }
}

customElements.define("tab-panel", TabPanel);

The ::slotted([slot="title"]) selector targets only the element assigned to the named slot, giving it bold styling without affecting any other text inside the component. This is how you style projected content from within the shadow DOM, using the ::slotted pseudo-element.

<tab-panel>
  <span slot="title">Settings</span>
  <p>Panel content goes here.</p>
</tab-panel>

Children with slot="title" project into the named slot, and any children without a slot attribute fall into the default slot. This two-tier system lets consumers of your component choose between structured and free-form content.

CSS custom properties and Shadow DOM

Styles inside shadow DOM are scoped and do not leak out. Use CSS custom properties to let external styles cross the shadow boundary:

this.shadowRoot.innerHTML = `
  <style>
    :host {
      background: var(--panel-bg, #ffffff);
      color: var(--panel-color, inherit);
      padding: var(--panel-padding, 1em);
    }
  </style>
  <slot></slot>
`;

The consumer sets custom properties on the element via the style attribute or a stylesheet, and the shadow DOM picks them up through the var() function. Each property has a fallback value (like #ffffff for --panel-bg) so the component still looks correct when no custom values are provided.

<my-panel style="--panel-bg: #f5f5f5;"></my-panel>

The component defines the property names; the consumer provides the values. This is the standard way to theme custom elements without JavaScript, and it works across all browsers that support both custom properties and shadow DOM.

Form-associated elements

Custom elements can participate in forms by adding static get formAssociated() and implementing the formAssociatedCallback:

class ColorPicker extends HTMLElement {
  static get formAssociated() { return true; }

  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  valueChanged(newValue) {
    this._internals.setFormValue(newValue);
  }
}

customElements.define("color-picker", ColorPicker);

This lets your element work with native form features: FormData, validation, and the form attribute selector.

Best Practices

Keep custom elements focused and single-purpose. Expose a minimal API: properties and attributes for configuration, events for notifications. Don’t reach into the shadow DOM from outside the element; let CSS custom properties and slot projection handle styling and composition.

Accessibility and State

Custom elements should feel like native controls where possible. That means thinking about keyboard support, focus order, labels, and state changes from the start. If your element acts like a button, it should behave like one for keyboard users as well as pointer users. If it displays dynamic state, that state should be visible to assistive technologies too.

Accessibility is much easier to add when it is part of the component contract. A well-designed element exposes the right attributes and reflects important state changes in a predictable way. That way the component can be reused in different pages without each consumer having to rebuild the same accessibility logic.

Testing lifecycle logic

Lifecycle callbacks can be tricky to test if you only look at the final rendered result. It helps to verify the transitions too: what happens when the element is attached, when an attribute changes, and when the element is removed again. Those states often expose bugs that a single snapshot would miss.

Testing also encourages you to keep the callbacks small. A callback that does one job is easier to assert against than one that mixes rendering, event wiring, and state updates. That separation gives the component a clearer shape and makes future changes less risky.

Shipping components well

The best custom elements are predictable for the consumer and simple for the author. Keep the public API small, keep the internals hidden, and let attributes and events carry most of the communication. That makes the component easier to document and easier to drop into other projects later.

It is also worth thinking about the fallback story. If the browser does not support one of your features, the element should still fail in a clear way. A component that degrades gracefully is much easier to adopt than one that needs a perfect environment just to render basic content.

State, events, and attributes

The strongest custom elements usually have a simple relationship between state, events, and attributes. Attributes configure the element, internal state drives rendering, and events tell the outside world that something changed. When those roles stay separate, the component is easier to reason about and easier to reuse in other pages.

That separation also makes the API easier to document. Consumers can see which attributes matter, which events they should listen for, and which pieces of state are only for the element itself. Clear boundaries make the component feel more like a native part of the platform.

Accessibility Review

It is worth reviewing custom elements with accessibility in mind after the first implementation pass. Check whether the element still makes sense with the keyboard, whether the visible label matches the action, and whether state changes are obvious without relying on color alone. These small checks catch a lot of issues before the component reaches a wider audience.

A component that is easy to review is also easier to maintain. If the behavior is predictable, future edits are less likely to break the user experience in subtle ways. That makes the custom element feel more like a stable building block and less like a special case that only one page understands.

Form and Focus

If your element participates in forms or behaves like an input, pay attention to focus handling and value updates. The user should always be able to tell when the component has accepted a change and when it has not. That clarity keeps the element feeling consistent with the rest of the platform.

Focus management is especially important for components that open overlays, hide content, or change state after a user action. A predictable focus path helps keyboard users stay oriented and makes the component easier to trust.

See Also