Skip to main content
Technology & EngineeringMicro Frontend286 lines

Web Components

Web Components and Shadow DOM as a framework-agnostic encapsulation layer for micro-frontend composition.

Quick Summary28 lines
You are an expert in using Web Components (Custom Elements, Shadow DOM, HTML Templates) as the integration layer for micro-frontend architectures. You help teams wrap framework-specific applications inside standards-based custom elements that compose cleanly regardless of the host technology.

## Key Points

- **Custom Elements** — `customElements.define()` registers a new HTML tag with lifecycle callbacks.
- **Shadow DOM** — an encapsulated DOM subtree with scoped styles.
- **HTML Templates / Slots** — declarative content projection.
- **Adopted Stylesheets** — performant, shareable CSS for shadow roots.
1. **Use `composed: true` on custom events** — without it, events dispatched inside Shadow DOM will not cross the shadow boundary and the shell will never hear them.
2. **Prefer properties over attributes for complex data** — attributes are always strings. For objects or arrays, expose a JavaScript property setter on the element.
3. **Use CSS custom properties for theming** — they are the intended mechanism for piercing Shadow DOM, enabling a shared design language without breaking encapsulation.
4. **Keep bundles self-contained** — each custom element script should bundle its own framework runtime (or share via import maps) so it works in any host.
5. **Register elements defensively** — check `customElements.get("mf-catalog")` before calling `define` to avoid errors when multiple scripts attempt registration.
6. **Leverage `adoptedStyleSheets`** — they are more performant than `<style>` tags and can be shared across multiple shadow roots.
- **Form participation** — custom elements with Shadow DOM do not natively participate in `<form>` submission. Use `ElementInternals` and `formAssociated` to restore form behavior.
- **SSR limitations** — Declarative Shadow DOM (`<template shadowrootmode="open">`) has growing support, but older browsers need a polyfill. Server-rendering shadow content is still maturing.

## Quick Example

```html
<!-- Usage -->
<mf-layout>
  <mf-filter-panel slot="sidebar"></mf-filter-panel>
  <mf-product-grid></mf-product-grid>
</mf-layout>
```
skilldb get micro-frontend-skills/Web ComponentsFull skill: 286 lines
Paste into your CLAUDE.md or agent config

Web Components — Micro-Frontends

You are an expert in using Web Components (Custom Elements, Shadow DOM, HTML Templates) as the integration layer for micro-frontend architectures. You help teams wrap framework-specific applications inside standards-based custom elements that compose cleanly regardless of the host technology.

Overview

Web Components provide a browser-native mechanism for defining self-contained, reusable UI elements. In a micro-frontend context, each team packages their application or widget as a custom element. The shell application composes these elements in HTML without knowing or caring about the internal framework. Shadow DOM provides style encapsulation; custom events provide communication.

Key building blocks:

  • Custom ElementscustomElements.define() registers a new HTML tag with lifecycle callbacks.
  • Shadow DOM — an encapsulated DOM subtree with scoped styles.
  • HTML Templates / Slots — declarative content projection.
  • Adopted Stylesheets — performant, shareable CSS for shadow roots.

Core Concepts

Custom Element Lifecycle

CallbackWhen it fires
constructor()Element created or upgraded
connectedCallback()Element inserted into the document
disconnectedCallback()Element removed from the document
attributeChangedCallback(name, oldVal, newVal)An observed attribute changes

These map directly to micro-frontend lifecycle needs: bootstrap in constructor, mount in connectedCallback, unmount in disconnectedCallback.

Style Encapsulation

Shadow DOM prevents styles from leaking in or out. This solves the global CSS collision problem that plagues other micro-frontend approaches. Styles defined inside the shadow root do not affect the outer document, and outer styles do not reach into the shadow root (except CSS custom properties, which intentionally pierce the boundary for theming).

Communication

Custom elements communicate outward via CustomEvent and inward via attributes/properties. This mirrors the props-down/events-up pattern familiar to component frameworks.

Implementation Patterns

Wrapping a React App in a Custom Element

// catalog-element.js
import React from "react";
import { createRoot } from "react-dom/client";
import CatalogApp from "./CatalogApp";

class CatalogElement extends HTMLElement {
  static get observedAttributes() {
    return ["category", "lang"];
  }

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this._root = null;
  }

  connectedCallback() {
    const mountPoint = document.createElement("div");
    this.shadowRoot.appendChild(mountPoint);
    this._root = createRoot(mountPoint);
    this._render();
  }

  disconnectedCallback() {
    if (this._root) {
      this._root.unmount();
      this._root = null;
    }
  }

  attributeChangedCallback() {
    this._render();
  }

  _render() {
    if (!this._root) return;
    this._root.render(
      <CatalogApp
        category={this.getAttribute("category")}
        lang={this.getAttribute("lang") || "en"}
        onProductSelect={(product) =>
          this.dispatchEvent(
            new CustomEvent("product-select", {
              detail: product,
              bubbles: true,
              composed: true,
            })
          )
        }
      />
    );
  }
}

customElements.define("mf-catalog", CatalogElement);

Wrapping a Vue App in a Custom Element

// checkout-element.js
import { createApp } from "vue";
import CheckoutApp from "./CheckoutApp.vue";

class CheckoutElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this._app = null;
  }

  connectedCallback() {
    const mountPoint = document.createElement("div");
    this.shadowRoot.appendChild(mountPoint);
    this._app = createApp(CheckoutApp, {
      cartId: this.getAttribute("cart-id"),
    });
    this._app.mount(mountPoint);
  }

  disconnectedCallback() {
    if (this._app) {
      this._app.unmount();
      this._app = null;
    }
  }
}

customElements.define("mf-checkout", CheckoutElement);

Shell Application Composing Custom Elements

<!-- Shell app — no framework knowledge needed -->
<body>
  <mf-navbar></mf-navbar>

  <main>
    <mf-catalog category="electronics" lang="en"></mf-catalog>
  </main>

  <script>
    document.querySelector("mf-catalog")
      .addEventListener("product-select", (e) => {
        console.log("User selected:", e.detail);
        // Navigate or update another micro-frontend
      });
  </script>

  <script type="module" src="https://navbar.example.com/element.js"></script>
  <script type="module" src="https://catalog.example.com/element.js"></script>
</body>

Shadow DOM with Adopted Stylesheets

// Shared design tokens via adopted stylesheets
const sharedTokens = new CSSStyleSheet();
sharedTokens.replaceSync(`
  :host {
    --color-primary: #0066cc;
    --color-surface: #ffffff;
    --font-family: system-ui, sans-serif;
    --radius: 4px;
  }
`);

class MyElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });

    const localStyles = new CSSStyleSheet();
    localStyles.replaceSync(`
      .card {
        background: var(--color-surface);
        border-radius: var(--radius);
        font-family: var(--font-family);
        padding: 16px;
      }
    `);

    shadow.adoptedStyleSheets = [sharedTokens, localStyles];
  }
}

Slot-Based Content Projection

// A layout wrapper micro-frontend
const template = document.createElement("template");
template.innerHTML = `
  <style>
    .layout { display: grid; grid-template-columns: 250px 1fr; gap: 16px; }
  </style>
  <div class="layout">
    <aside><slot name="sidebar">Default sidebar</slot></aside>
    <main><slot>Default content</slot></main>
  </div>
`;

class LayoutElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

customElements.define("mf-layout", LayoutElement);
<!-- Usage -->
<mf-layout>
  <mf-filter-panel slot="sidebar"></mf-filter-panel>
  <mf-product-grid></mf-product-grid>
</mf-layout>

Lazy-Loading Custom Element Definitions

// Only load the element bundle when the tag appears in the DOM
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const tag = entry.target.tagName.toLowerCase();
      if (!customElements.get(tag)) {
        const src = entry.target.dataset.src;
        import(/* webpackIgnore: true */ src);
      }
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll("[data-src]").forEach((el) => observer.observe(el));

Best Practices

  1. Use composed: true on custom events — without it, events dispatched inside Shadow DOM will not cross the shadow boundary and the shell will never hear them.
  2. Prefer properties over attributes for complex data — attributes are always strings. For objects or arrays, expose a JavaScript property setter on the element.
  3. Use CSS custom properties for theming — they are the intended mechanism for piercing Shadow DOM, enabling a shared design language without breaking encapsulation.
  4. Keep bundles self-contained — each custom element script should bundle its own framework runtime (or share via import maps) so it works in any host.
  5. Register elements defensively — check customElements.get("mf-catalog") before calling define to avoid errors when multiple scripts attempt registration.
  6. Leverage adoptedStyleSheets — they are more performant than <style> tags and can be shared across multiple shadow roots.

Common Pitfalls

  • Form participation — custom elements with Shadow DOM do not natively participate in <form> submission. Use ElementInternals and formAssociated to restore form behavior.
  • SSR limitations — Declarative Shadow DOM (<template shadowrootmode="open">) has growing support, but older browsers need a polyfill. Server-rendering shadow content is still maturing.
  • Event retargeting — events that cross shadow boundaries have their target retargeted to the host element. If you need the original target, use event.composedPath().
  • Framework hydration conflicts — mounting React/Vue inside a shadow root can confuse hydration if the server-rendered HTML does not match. Prefer client-only rendering inside custom elements.
  • Memory leaks on disconnect — failing to unmount the inner framework in disconnectedCallback leaks the entire component tree.
  • Over-nesting shadow roots — deeply nested shadow DOMs make debugging painful and can hurt performance. Keep the nesting shallow.

Core Philosophy

Web Components are the browser-native solution to the micro-frontend composition problem. Custom elements let each team package their application as a self-contained HTML tag that the shell can compose declaratively, without knowing or caring about the internal framework. Shadow DOM provides the style encapsulation that prevents CSS collisions. Custom events provide the communication mechanism. These are standards, not library features — they work in every browser and every framework.

The lifecycle callbacks of custom elements map directly to micro-frontend lifecycle needs. connectedCallback is mount: create the React root, render the Vue app, or initialize the Angular component. disconnectedCallback is unmount: destroy the framework instance and clean up listeners. attributeChangedCallback is props update: re-render with new configuration. This mapping is clean, predictable, and requires no external orchestration framework.

CSS custom properties are the intended mechanism for theming across shadow boundaries. They are the only CSS values that pierce the shadow DOM by design, making them perfect for shared design tokens. Define your design system as CSS custom properties on :root, and every custom element — regardless of its internal framework — can reference them for consistent styling without breaking encapsulation.

Anti-Patterns

  • Forgetting composed: true on custom events — events dispatched inside Shadow DOM do not cross the shadow boundary by default; without composed: true, the shell never receives them.

  • Passing complex data through HTML attributes — attributes are always strings; for objects, arrays, or functions, expose JavaScript property setters on the element instead of serializing to attribute strings.

  • Not checking customElements.get() before define() — if multiple scripts attempt to define the same custom element tag, the second call throws; always check for existing registration first.

  • Failing to unmount the inner framework in disconnectedCallback — removing the custom element from the DOM without unmounting React, Vue, or Angular inside it leaks the entire component tree and its associated memory.

  • Deeply nesting shadow roots — each level of Shadow DOM nesting adds debugging complexity and performance overhead; keep the shadow root hierarchy shallow and use composition via slots rather than nested custom elements.

Install this skill directly: skilldb add micro-frontend-skills

Get CLI access →