Building A Web Component

Putting custom elements, HTML templates, and Shadow DOM together into a real component. connectedCallback, loading CSS with fetch, and why the constructor cannot touch the DOM.

May 1, 20265 min read3 / 3

Knowing all three APIs separately is not enough. When I first tried combining them, I hit a wall immediately: appendChild in the constructor throws an error. The browser forbids a custom element from having children at construction time. Understanding why -- and where the actual DOM work belongs -- is what makes the whole pattern click.

The Essentials

  1. connectedCallback is where DOM work happens: The constructor cannot append children. connectedCallback fires after the element is in the document, so that is where you clone the template and build the component's DOM.
  2. Append to the shadow root, not this: Once you have a shadow root, all child elements go there. Appending to this puts them in the main DOM where styles are not scoped.
  3. CSS with fetch: Without a bundler, the cleanest way to load component-specific CSS into a shadow DOM is to fetch the .css file as text, create a <style> element, and append it to the shadow root.
  4. Constructor only - no async: The constructor cannot be async. If you need to await something during setup, put it in a regular function and call that function from the constructor.
  5. Base class pattern: All three setup steps (attach shadow, clone template, load CSS) repeat for every component. Extracting them into a base class that your components extend removes that repetition.

A Complete MenuPage Component

JavaScript
// components/MenuPage.js class MenuPage extends HTMLElement { constructor() { super(); this.root = this.attachShadow({ mode: 'open' }); this.loadCSS(); } async loadCSS() { const response = await fetch('/components/MenuPage.css'); const css = await response.text(); const style = document.createElement('style'); style.textContent = css; this.root.appendChild(style); } connectedCallback() { const template = document.getElementById('menu-page-template'); const content = template.content.cloneNode(true); this.root.appendChild(content); } } customElements.define('menu-page', MenuPage); export default MenuPage;

Three responsibilities, three separate steps:

  • The constructor attaches the shadow root and starts the CSS fetch.
  • loadCSS fetches the CSS file, creates a <style> element, and appends it to the shadow root.
  • connectedCallback clones the template and appends it to the shadow root.

Why the Constructor Cannot Append Children

This code throws an error:

JavaScript
constructor() { super(); const template = document.getElementById('menu-page-template'); const content = template.content.cloneNode(true); this.appendChild(content); // Error: result must not have children }

The spec prohibits appending children in the constructor. The element may not be connected to any document at construction time, and the surrounding DOM may not be ready. connectedCallback is the guaranteed-safe moment.

With Shadow DOM, the rule is more relaxed - appending to the shadow root in the constructor is allowed, because the shadow root is attached to the element itself, not to the document. Some developers move all DOM setup into the constructor when using Shadow DOM. Both approaches work; the example above separates them for clarity.

Loading CSS into a Shadow Root

Shadow DOM's style isolation cuts both ways. Page-level styles do not apply inside the shadow root - which means neither do your global fonts or baseline resets. Component-specific styles must be explicitly brought in.

Fetching the CSS as text and injecting it as a <style> element is one approach:

JavaScript
async loadCSS() { const response = await fetch('/components/MenuPage.css'); const css = await response.text(); const style = document.createElement('style'); style.textContent = css; this.root.appendChild(style); }

The fetch call returns text, not JSON, so response.text() is used instead of response.json(). The string goes directly into the <style> element's textContent.

For performance, the CSS file can be prefetched in the HTML head:

HTML
<link rel="prefetch" href="/components/MenuPage.css" as="style">

The browser starts downloading it immediately. By the time the component's fetch call runs, the file is already in the cache.

Registering Components in app.js

Simply importing the component file causes the module to execute, which runs customElements.define:

JavaScript
// app.js import './components/MenuPage.js'; import './components/DetailsPage.js'; import './components/OrderPage.js';

The imports do not need to be used for anything - loading the file is enough. The browser now recognizes <menu-page>, <details-page>, and <order-page> as valid elements.

Using Components from the Router

The router creates page elements using document.createElement:

JavaScript
export function renderRoute(route) { let pageElement; switch (route) { case '/': pageElement = document.createElement('menu-page'); break; case '/cart': pageElement = document.createElement('order-page'); break; default: if (route.startsWith('/product/')) { pageElement = document.createElement('details-page'); pageElement.dataset.id = route.substring('/product/'.length); } } const main = document.querySelector('main'); if (main.children[0]) main.children[0].remove(); if (pageElement) main.appendChild(pageElement); window.scrollY = 0; }

Appending pageElement to the DOM is what triggers connectedCallback on the component.

The Base Class Pattern

The three setup steps repeat for every component. A base class handles them once:

JavaScript
// components/BaseComponent.js class BaseComponent extends HTMLElement { constructor(templateId, cssFile) { super(); this.root = this.attachShadow({ mode: 'open' }); this.templateId = templateId; this.cssFile = cssFile; this.loadCSS(); } async loadCSS() { const response = await fetch(this.cssFile); const css = await response.text(); const style = document.createElement('style'); style.textContent = css; this.root.appendChild(style); } connectedCallback() { const template = document.getElementById(this.templateId); const content = template.content.cloneNode(true); this.root.appendChild(content); } }
JavaScript
// components/MenuPage.js class MenuPage extends BaseComponent { constructor() { super('menu-page-template', '/components/MenuPage.css'); } } customElements.define('menu-page', MenuPage);

The repetitive wiring disappears. Each component only declares what is specific to it.

Further Reading and Watching

Video:

  • Web Components Crash Course by Traversy Media. Builds a real web component with templates and Shadow DOM - directly applicable to this pattern.