Custom Elements

Custom Elements and Their Lifecycle

A custom element is a class that extends HTMLElement, registered with customElements.define(name, class). The name must start with a lowercase letter and contain a hyphen (user-card), so it can never collide with a future built-in tag, and it can be defined only once. Autonomous custom elements have been Baseline since January 2020. Avoid the other form, customized built-in elements (<button is="fancy-button">): Chrome 1 and Firefox 555 support it, but Safari 10 has said it never will.

The browser calls up to five lifecycle callbacks on your class. The listing logs them while an element is created, upgraded, changed, moved, adopted by another document and removed.

Logging every lifecycle callback of a custom elementJavaScript
class LifeLogger extends HTMLElement {
  static observedAttributes = ['status'];   // only these attributes trigger the callback
  constructor() { super(); console.log('constructor'); }
  connectedCallback() { console.log(`connected        parent=<${this.parentNode.localName}>`); }
  disconnectedCallback() { console.log('disconnected'); }
  adoptedCallback() { console.log('adopted'); }
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`attributeChanged ${name}: ${oldValue} -> ${newValue}`);
  }
}
const el = document.createElement('life-logger');   // not defined yet: a plain HTMLElement
document.body.append(el);
el.setAttribute('status', 'draft');
console.log('-- define --');
customElements.define('life-logger', LifeLogger);    // upgrade runs the callbacks now
console.log('-- change attribute --');
el.setAttribute('status', 'ready');
console.log('-- move --');
document.body.prepend(document.createElement('main'));
document.querySelector('main').append(el);
console.log('-- adopt into a new document --');
document.implementation.createHTMLDocument().body.append(el);
console.log('-- remove --');
el.remove();
Output
-- define --
constructor
attributeChanged status: null -> draft
connected        parent=<body>
-- change attribute --
attributeChanged status: draft -> ready
-- move --
disconnected
connected        parent=<main>
-- adopt into a new document --
disconnected
adopted
connected        parent=<body>
-- remove --
disconnected

Notice the upgrade: the element existed before its definition, so define() ran the constructor, replayed attributeChangedCallback for the observed attribute already present, then called connectedCallback. The same happens to tags parsed before your script loads. A move is a disconnect plus a connect, which resets state such as running animations; Chrome 133 and Firefox 144 add parent.moveBefore(el, ref), which calls connectedMoveCallback() instead, but Safari does not support it yet.

The custom element lifecycle and the event that triggers each callback
The custom element lifecycle and the event that triggers each callback
Form-associated elements and custom states

A custom control can join forms like a native <input>. Set static formAssociated = true, call this.attachInternals(), and use the returned ElementInternals to submit a value (setFormValue()) and report validity (setValidity()); <label>, disabled and form reset then work too. Its states set holds custom states that CSS matches with :state(). Form-associated custom elements are Baseline Widely available since September 2025, and :state() is Baseline since May 2024.

A star rating that submits with its form, validates, and exposes a custom stateHTMLLive
<style>
  body { font: 15px system-ui; margin: 16px; }
  star-rating { font-size: 28px; color: #b0bec5; cursor: pointer; }
  star-rating:state(rated) { color: #ef6c00; }
</style>
<form id="f">
  <label for="r">Rate this book:</label> <star-rating id="r" name="stars" value="4"></star-rating>
  <button>Send</button> <output id="o"></output>
</form>
<script>
  customElements.define('star-rating', class extends HTMLElement {
    static formAssociated = true;
    #internals = this.attachInternals();
    constructor() {
      super();
      this.addEventListener('click', (e) => (this.value = Number(e.target.dataset.n)));
    }
    connectedCallback() { this.value = Number(this.getAttribute('value')); }
    formResetCallback() { this.value = 0; }
    set value(v) {
      this.innerHTML = [1, 2, 3, 4, 5]
        .map((n) => `<span data-n="${n}">${n <= v ? '★' : '☆'}</span>`).join('');
      this.#internals.setFormValue(String(v));
      this.#internals.setValidity(v ? {} : { valueMissing: true }, 'Pick a rating');
      if (v) this.#internals.states.add('rated'); else this.#internals.states.delete('rated');
    }
  });
  const f = document.getElementById('f');
  f.onsubmit = (e) => {
    e.preventDefault();
    document.getElementById('o').value = new URLSearchParams(new FormData(f));
  };
  f.requestSubmit(); // submit once for the screenshot
</script>
Browser output of Listing 2.39
Browser output of 39

The output shows the submitted form data, stars=4, produced by an element the browser had never heard of.

Pitfalls. Do not read attributes or add children in the constructor; the spec forbids it. connectedCallback can run many times, so keep one-time setup (like the click listener above) in the constructor and stop timers in disconnectedCallback. Hide not-yet-upgraded tags with :not(:defined) { visibility: hidden; }.