Custom Elements

A custom element is a class registered against a tag name via customElements.define(). Registration and basic lifecycle are well understood; the rules below target the parts routinely implemented incorrectly.

Lifecycle: the parts that get implemented wrong

The callbacks themselves are well documented; these are the misconceptions that cause bugs:

  • connectedCallback can run more than once. It fires on every insertion, so moving the element re-runs it. Make setup idempotent — don't assume a single first run.
  • connectedMoveCallback skips the disconnect/reconnect pair on a move. When an element is relocated with Node.moveBefore(), this callback fires instead of disconnectedCallback → connectedCallback, so state, focus, and iframe/media playback survive the move. Define it when your setup or teardown is expensive or observably re-runs.
  • Tear down in disconnectedCallback. Remove global listeners, timers, and observers or you leak; but the element may be re-inserted, so don't treat teardown as permanent.
  • Call super() first in the constructor, and do not depend on the full DOM being present. Calling super() after touching this throws a ReferenceError. Attributes are usually (but not always) present; long lists of children may not have finished parsing. Use connectedCallback for one-shot initialization, slotchange to monitor direct children, and attributeChangedCallback to monitor attributes. connectedCallback may still fire before all children are parsed, so do not ignore subsequent slotchange / attributeChangedCallback calls.
  • attributeChangedCallback fires only for observedAttributes, and should stay cheap for attributes that don't affect output.
  • Lifecycle hooks and observedAttributes are snapshotted at registration. customElements.define() reads both once; adding a hook or extending observedAttributes on the class after the call is silently ignored. In particular, attributeChangedCallback never fires unless observedAttributes was already present at define() time.

Upgrade timing and :defined

An element can exist in the DOM before its class is registered (in server-rendered HTML, or when the script loads late). Until defined it is an inert HTMLElement with none of your behavior; when customElements.define() runs, the browser upgrades every matching element already in the document, running its constructor and connectedCallback then.

Two consequences models routinely miss:

  1. Attributes and children may already be present when your constructor/connectedCallback runs on upgrade. Read existing attributes in connectedCallback to sync initial state, and never assume the element started empty.
  2. Children may still be missing even in connectedCallback — the HTML parser can yield mid-parse of long child lists, so the callback sometimes runs before every child is attached. See the constructor bullet in Lifecycle above for the pattern (slotchange + attributeChangedCallback).

Hide undefined elements to avoid a flash of unstyled content — but fail open, so the content stays reachable if the script never loads. Animate opacity with a delay rather than hard-hiding with visibility: hidden, so the element reveals itself after a timeout even when it is never upgraded:

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

Scope these guards to the elements that need them — or to a specific subtree via :has(:not(:defined)) — rather than the whole body. One slow, non-critical widget (a share button, say) should not delay paint for the entire page.

Attribute ⇄ property reflection

Keep the HTML attribute and the JS property in sync so the element is usable from both markup and script. Attribute values are always strings; coerce them.

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

Where the value lives

The example above keeps the DOM as the source of truth — each getter reads getAttribute. That's the right default for a handful of simple, string-ish attributes: there is only one place the state can live, so it can't desync.

For richer components, keep the source of truth in a JS field and reflect outward to an attribute only when something else must see it — CSS targeting, or a consumer reading it. Reading a field is far cheaper than parsing and coercing an attribute on every access, and non-string values never round-trip through serialization. This is why libraries like Lit hold state in JS and reflect selectively rather than treating attributes as storage: reflect only what the outside world must observe, not every property.

Pitfalls:

  • observedAttributes discipline: list only attributes whose change must re-render or trigger a side effect. Observing everything adds a callback to every mutation for no benefit.
  • Reflection loops: a setter that writes an attribute will re-enter attributeChangedCallback. Guard with an oldVal !== newVal check, as above.
  • Style-only state: for internal state that exists only to drive styling (loading, active, expanded), prefer custom states over reflecting a synthetic attribute — see Custom states below. Reflect to an attribute only when consumers must be able to set the state from markup.

Custom states

Custom states are boolean flags an element exposes about itself for CSS to target, set through ElementInternals and matched with the :state() pseudo-class. Use them for internal, runtime UI state — busy, expanded, invalid — instead of reflecting a synthetic attribute: they can't collide with a consumer's attributes, never appear in the DOM, and are read-only from outside (a consumer can style :state() but can't forge it).

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet is a set, so independent states compose (busy plus error) without inventing a combined data-status enum.

Custom state is runtime-only. It is not serialized to the DOM, so it does not survive a reload, the element being re-created, or server rendering. If the state must be set declaratively in markup or persist across loads, that is a job for an attribute (see Where the value lives) — restore it in connectedCallback from your source of truth. Rule of thumb: custom state for what the component derives and owns at runtime and only needs to style; an attribute for what must be set from markup or serialized.

For styling these states — both inside the component and from a consumer's stylesheet — see Styling Web Components.

Naming conventions

  • True-private state: native # fields (#count) for hard encapsulation; a _ prefix only when subclasses must still reach it. Note # fields are not inherited — a subclass cannot see a base class's private fields.
  • Symbol-keyed properties are a middle ground: hidden from ordinary enumeration, but reachable by any code holding the symbol (export it, or expose it as a static field on the class). Because each symbol is unique, two symbols sharing a description never collide — handy when a subclass or sibling needs controlled access that # fields can't provide.
  • Do not shadow global attributes (style, class, id, slot, part, title, lang, dir) with your own properties; doing so breaks their built-in behavior. Note disabled is only global on form controls; on other elements you must implement its effect yourself.

When naming a new API surface, follow the platform's own conventions: use lowercase kebab-case with at least one hyphen for custom element tag names (for example my-toggle), lowerCamelCase for properties and methods, and lowercase attribute names synchronized with their corresponding properties.