How to Define Custom Elements Without Frameworks

Modern UI architecture increasingly favors framework-agnostic primitives. Defining custom elements natively eliminates vendor lock-in and reduces bundle overhead. The foundation of this approach relies on the Custom Element Registry & Definition API. This API provides deterministic registration, lifecycle hooks, and strict validation rules. This guide addresses common registration failures, performance bottlenecks, and production-safe implementation patterns.

Constructor vs. connectedCallback responsibilities A split diagram contrasting what is allowed in the constructor against the work deferred to connectedCallback to stay parse-safe. constructor() super() first attachShadow({ mode }) init private #fields no child DOM reads no fetch / no appendChild inserted connectedCallback() guard re-entrancy (#initialized) render innerHTML bind events & observers read assigned slot children teardown in disconnectedCallback

Minimal Reproducible Definition Pattern

A production-safe custom element requires strict adherence to the HTMLElement extension pattern. Constructor execution must remain synchronous and side-effect free. The following ES2022+ implementation demonstrates correct structure without framework abstractions:

class BaseWidget extends HTMLElement {
  static get observedAttributes() {
    return ['theme', 'disabled'];
  }

  #state = { initialized: false };

  constructor() {
    super();
    // Attach the shadow root here per spec — the constructor is the correct place.
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    if (this.#state.initialized) return;
    this.shadowRoot.innerHTML = this.#render();
    this.#bindEvents();
    this.#state.initialized = true;
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal && this.#state.initialized) {
      this.#updateAttribute(name, newVal);
    }
  }

  disconnectedCallback() {
    this.#cleanup();
  }

  #render() {
    return `<style>:host { display: block; }</style><slot></slot>`;
  }

  #bindEvents() {
    /* Event delegation setup */
  }
  #updateAttribute(name, val) {
    /* Reactive sync logic */
  }
  #cleanup() {
    /* Listener removal */
  }
}

if (!customElements.get('base-widget')) {
  customElements.define('base-widget', BaseWidget);
}

The constructor must call super() first. attachShadow() should be called in the constructor — it is the required place per the Custom Elements v1 spec and does not throw in any modern browser. Defer heavy DOM rendering (populating innerHTML) and event binding to connectedCallback(), where you can safely guard against re-entrancy.

Root-Cause Analysis for Registration Failures

Framework-agnostic implementations frequently encounter InvalidCharacterError, SyntaxError, or NotSupportedError during definition. These errors stem from three primary root causes:

Resolving these requires strict lifecycle separation. Understanding broader Core Architecture & Lifecycle Management principles ensures components remain parse-safe and upgradeable. It also guarantees interoperability with SSR pipelines.

Performance Optimization & Production Hardening

Native custom elements offer near-zero runtime overhead when implemented correctly. Naive implementations degrade main-thread performance during hydration and reflow. Apply these production-safe optimizations:

Performance Tradeoffs:

Benchmarking indicates that deferring heavy template compilation to first interaction reduces Time to Interactive (TTI) by 18–24%. This is critical for component-heavy dashboards.

Implementation Checklist for Framework-Agnostic Systems

Validate your custom element against these architectural constraints before shipping:

Adhering to these constraints guarantees interoperability across React, Vue, Angular, and vanilla environments. It maintains strict compliance with the W3C Web Components specification.

Debugging & Environment Notes

Use Chrome DevTools Elements panel to inspect the registry via console. Run customElements.get('tag-name') to verify registration status. Verify lifecycle execution order using console.trace() in each callback. Monitor layout shifts with the Performance tab during connectedCallback execution. Native Custom Elements v1 is supported in all evergreen browsers. Polyfills are only required for IE11 or legacy enterprise environments.

Valid and invalid custom element names A name must start with a lowercase letter, contain a hyphen, use no uppercase characters, and avoid a short list of reserved SVG and MathML names. define() throws SyntaxError on anything below the line wfc-card my-app-data-grid card — no hyphen Wfc-Card — uppercase -card — must start with a letter font-face — reserved The hyphen is what reserves the whole namespace for authors — which is why no built-in tag will ever collide.

Frequently Asked Questions

Do I need a build step to write a custom element?

No. A class extending HTMLElement, a customElements.define call, and a <script type="module"> tag are the whole requirement. Build tooling adds bundling, types, and manifests — none of which the platform needs.

Why must the tag name contain a hyphen?

It reserves the hyphenated namespace for authors, guaranteeing that no future built-in element can ever collide with a name you chose. define throws SyntaxError for a name without one.

Where should the shadow root be created?

In the constructor, so the tree exists before insertion. Only work that depends on being in a document — listeners, observers, measurements — belongs in connectedCallback.

How do I avoid re-parsing the template for every instance?

Create a <template> once at module scope and clone its content per instance, and adopt a shared constructable stylesheet rather than writing a <style> element into each root. Both changes are confined to the constructor.

The four pieces a framework-free component actually needs A class, a shadow root, a registration call and a module script are the complete requirement; everything else in a toolchain is optional tooling. Everything required, and nothing more a class extends HTMLElement a shadow root attached in the constructor a registration customElements.define a module script deferred by default Bundlers, types and manifests improve the developer and consumer experience; the browser asks for none of them.

A complete component with no dependencies

Putting the pieces together produces a component with no build step, no framework, and no runtime beyond the platform. The template and stylesheet live at module scope so they are parsed once; the constructor clones and adopts; connection registers listeners against a per-connection signal; disconnection releases them.

const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
  <button part="trigger" type="button" aria-expanded="false">
    <slot name="label">Details</slot>
  </button>
  <div part="panel" hidden><slot></slot></div>`;

const SHEET = new CSSStyleSheet();
SHEET.replaceSync(`
  :host { display: block; }
  [part~="trigger"] { font: inherit; cursor: pointer; }
  [part~="panel"] { padding-block-start: 0.5rem; }
  [part~="panel"][hidden] { display: none; }
`);

class DisclosurePanel extends HTMLElement {
  #controller = null;

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    root.adoptedStyleSheets = [SHEET];
    root.append(TEMPLATE.content.cloneNode(true));
  }

  connectedCallback() {
    this.#controller = new AbortController();
    const trigger = this.shadowRoot.querySelector('[part~="trigger"]');
    trigger.addEventListener('click', () => this.toggle(), {
      signal: this.#controller.signal
    });
  }

  disconnectedCallback() {
    this.#controller?.abort();
    this.#controller = null;
  }

  toggle(force) {
    const panel = this.shadowRoot.querySelector('[part~="panel"]');
    const open = force ?? panel.hidden;
    panel.hidden = !open;
    this.shadowRoot.querySelector('[part~="trigger"]')
      .setAttribute('aria-expanded', String(open));
    this.dispatchEvent(new CustomEvent('wfc-toggle', {
      detail: { open }, bubbles: true, composed: true
    }));
  }
}
customElements.define('disclosure-panel', DisclosurePanel);

Every decision in that file is one the platform asked for rather than a convention: the template at module scope avoids a parse per instance, delegatesFocus makes the host behave like a control, the parts give consumers a styling surface, and the composed event is the component’s public signal. Nothing here needs a bundler, and adding one later changes none of it.

One last habit is worth adopting from the start: keep the class and its registration in separate modules even for a single component. It costs one file, and it means the class can be subclassed, registered under a different name, or used with a scoped registry without any refactoring later. Libraries that skip this reliably discover the need after their first external consumer asks for it, at which point the entry-point change is a breaking one.

Where a framework would have helped, and where it would not

Writing a component without a framework makes the tradeoff explicit. What the platform gives directly is registration, lifecycle, encapsulation, projection, events, and form participation — all of it standardised and none of it requiring a dependency. What a framework adds on top is templating with reactive bindings, and for a component whose rendering is more than a handful of elements, that difference is real.

The useful conclusion is not “avoid frameworks” but “choose per component”. A disclosure panel, a badge, a tab strip, or a form control has little enough markup that hand-written DOM is clearer than a template language. A data grid with virtualised rows and sortable columns is not, and a small rendering library pays for itself immediately there. Because the public surface is identical either way — a tag, attributes, properties, events, slots, parts — that decision stays internal and can differ between components in the same library.

What does not vary is the registration itself: whichever way a component renders internally, it is defined the same way and consumed the same way.

That stability is the practical value of building on the platform: the contract a consumer depends on is defined by standards rather than by whichever library the component happened to be written with, and it survives the component being rewritten.