ESC

Type to search the knowledge base.

Custom Elements

Web Components custom elements — define, connectedCallback, attributes vs properties, and autonomous vs customized built-ins.

advanced3 min read
  • javascript
  • custom-elements
  • web-components

Custom Elements let you teach the browser new tags (<user-card>) with a class lifecycle. Combined with Shadow DOM and templates they form Web Components. Even if your app is React-only, design systems and embeds use them — and interviews ask for the lifecycle by name.

Define and register

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

  constructor() {
    super(); // required first
    this._root = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  disconnectedCallback() {
    // remove listeners, observers
  }

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

  render() {
    const name = this.getAttribute('name') ?? '';
    const role = this.getAttribute('role') ?? '';
    this._root.innerHTML = `
      <style>
        :host { display: block; border: 1px solid #ccc; padding: 8px; }
        .role { opacity: 0.7; font-size: 0.9em; }
      </style>
      <strong>${escapeHtml(name)}</strong>
      <div class="role">${escapeHtml(role)}</div>
    `;
  }
}

customElements.define('user-card', UserCard);
<user-card name="Ada" role="Engineer"></user-card>

Names must include a hyphen. Registration is global per page.

Lifecycle cheat sheet

Callback When
constructor Element created / upgraded
connectedCallback Inserted into document
disconnectedCallback Removed from document
attributeChangedCallback Observed attribute changes
adoptedCallback Moved to new document

Create DOM in connectedCallback if you need attributes to be present; constructor runs too early for some cases (and for SSR upgrade timing).

Attributes vs properties

Attributes are strings in HTML. Properties are JS values on the instance.

class CounterEl extends HTMLElement {
  #count = 0;

  get count() {
    return this.#count;
  }
  set count(n) {
    this.#count = Number(n) || 0;
    this.setAttribute('count', String(this.#count));
    this.render?.();
  }

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

  attributeChangedCallback(name, _, value) {
    if (name === 'count') this.#count = Number(value) || 0;
  }
}

Reflect carefully to avoid infinite attribute ↔ property loops.

Autonomous vs customized built-ins

// autonomous
customElements.define('fancy-button', FancyButton);

// customized built-in (limited Safari history — check support)
class FancyButton extends HTMLButtonElement {}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// <button is="fancy-button">

Prefer autonomous elements for portability unless you need built-in semantics without reinventing accessibility.

Interop with frameworks

React can render custom elements as tags; property passing and event names need care (DOM events vs props). Prefer addEventListener for component events:

this.dispatchEvent(
  new CustomEvent('count-change', {
    detail: { count: this.#count },
    bubbles: true,
    composed: true, // cross shadow boundary
  }),
);

Footguns

  1. Forgetting super() in the constructor.
  2. Unescaped innerHTML from attributes → XSS.
  3. Doing heavy work every attributeChangedCallback without diffing.
  4. Assuming Shadow DOM is required — light DOM custom elements exist.
  5. Double-defining the same tag throws.

Interview answer

“Custom elements subclass HTMLElement, register with customElements.define and a hyphenated name. Lifecycle hooks cover connect, disconnect, and attribute changes. I reflect attributes carefully, escape HTML, and use CustomEvent with composed for shadow-piercing events. They’re great for design-system primitives and cross-framework embeds.”

Upgrade and whenDefined

// element may exist in HTML before the class loads
customElements.whenDefined('user-card').then(() => {
  console.log('ready');
});

// progressive enhancement: unknown elements still render light DOM children
<user-card>
  <img slot="avatar" src="a.jpg" alt="" />
</user-card>

Combine with <template> + shadow slots for content projection (template and slot). Form-associated custom elements (ElementInternals, formAssociated = true) integrate with native forms — advanced but important if you replace <input> with a widget and still want FormData to see values.

Further reading

Related guides