Native Web Components: Custom Elements, Shadow DOM, Templates, Events and Framework Integration

Key takeaways

Web Components are a suite of browser standards that let you create reusable custom elements with encapsulated functionality. They work everywhere without frameworks.

Introduction

Web Components are a set of web platform APIs that let you define custom, reusable HTML tags with encapsulated markup, styles, and behavior. Unlike a React component or a Vue single-file component, a Web Component is not compiled into anything — the browser understands <my-button> natively, the same way it understands <button>. That single fact is both the appeal and the source of most of the friction developers hit when adopting them: you get true framework independence, but you also lose every convenience a framework normally gives you for free, such as declarative templating, reactive re-rendering, and prop diffing.

This matters most in two very specific situations: design systems that need to be consumed by teams on different frameworks (one team on React, another on Vue, another still on server-rendered Rails views), and long-lived embeddable widgets (a chat bubble, a video player, an ad unit) that have to survive framework version churn for years. If your entire codebase is a single React application with a single team, Web Components rarely buy you enough to justify the extra ceremony — a plain React component will be faster to write, easier to test, and easier to onboard new hires into. Web Components earn their complexity budget specifically at organizational boundaries, not inside a single app.

The Three Pillars

  1. Custom Elements - Define new HTML tags
  2. Shadow DOM - Encapsulate styles and markup
  3. HTML Templates - Reusable HTML chunks

Why Web Components?

Problem with frameworks:

// React component only works in React
function MyButton({ text }) {
  return <button>{text}</button>;
}

// Vue component only works in Vue
export default {
  template: '<button>{{ text }}</button>'
}

Web Components work everywhere:

// Works in React, Vue, Angular, or vanilla JS
class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button>${this.textContent}</button>`;
  }
}
customElements.define('my-button', MyButton);
<!-- Use anywhere -->
<my-button>Click me</my-button>

The trade-off hiding in this example is easy to miss: MyButton above re-reads this.textContent and re-renders the entire innerHTML string on every invocation of connectedCallback. There is no virtual DOM, no diffing, and no built-in mechanism to update only the parts that changed. If you want anything resembling React’s re-render-on-state-change model, you have to build it yourself — track a private field, call a render() method on every mutation, and manually decide whether to blow away innerHTML (simple, but destroys focus state and loses any DOM nodes the browser was tracking) or patch specific nodes (correct, but more code). This is the single biggest reason libraries like Lit exist: Lit adds a lightweight, tagged-template-based reactive rendering layer on top of the raw Custom Elements API so you don’t have to hand-write that diffing logic yourself.

Custom Elements

A custom element class extends HTMLElement (or a built-in subclass, for “customized built-in elements” like <button is="fancy-button">) and gets registered with the browser via customElements.define(tagName, ClassName). The spec deliberately restricts what you can do in the constructor: you cannot read attributes, inspect children, or add children in the constructor, because at that point the element may not yet be attached to the document and its attributes may not have been parsed. All of that real setup work belongs in connectedCallback(), which fires once the element is actually inserted into the DOM. Getting this backwards — reading this.getAttribute(...) inside the constructor — is one of the most common bugs newcomers hit, and it fails silently rather than throwing, which makes it harder to spot.

Basic Custom Element

class HelloWorld extends HTMLElement {
  connectedCallback() {
    this.innerHTML = '<h1>Hello, World!</h1>';
  }
}

customElements.define('hello-world', HelloWorld);
<hello-world></hello-world>

With Attributes

class UserCard extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('name') || 'Guest';
    const role = this.getAttribute('role') || 'User';
    
    this.innerHTML = `
      <div class="user-card">
        <h2>${name}</h2>
        <p>${role}</p>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);
<user-card name="Alice" role="Developer"></user-card>

This example is intentionally simplified and has a real security gap you should not copy into production: interpolating name directly into innerHTML is an XSS vector the moment name comes from user input rather than a hardcoded attribute in your own markup. If name can ever contain <img src=x onerror=alert(1)>, that markup executes. The fix is either to use textContent assignment for untrusted strings (h2.textContent = name, set after building the skeleton with static innerHTML), or to sanitize before interpolation. This is not a Web Components-specific problem — the same bug exists in any framework that lets you interpolate raw HTML — but Custom Elements make it easy to reach for innerHTML as the default rendering strategy, so the risk shows up more often in code written directly against the raw API.

Lifecycle Callbacks

class MyElement extends HTMLElement {
  // Called when element is created
  constructor() {
    super();
    console.log('Constructor');
  }
  
  // Called when added to DOM
  connectedCallback() {
    console.log('Connected');
    this.render();
  }
  
  // Called when removed from DOM
  disconnectedCallback() {
    console.log('Disconnected');
  }
  
  // Called when moved to new page
  adoptedCallback() {
    console.log('Adopted');
  }
  
  // Called when attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`${name} changed from ${oldValue} to ${newValue}`);
    this.render();
  }
  
  // Specify which attributes to observe
  static get observedAttributes() {
    return ['name', 'age'];
  }
  
  render() {
    const name = this.getAttribute('name');
    this.innerHTML = `<p>Hello, ${name}!</p>`;
  }
}

customElements.define('my-element', MyElement);

The ordering of these callbacks trips people up in a specific way: if an element is created with an attribute already present in the markup (<my-element name="Alice">), attributeChangedCallback fires for that initial attribute before connectedCallback runs, not after. If your render() method assumes this.shadowRoot or other setup from connectedCallback already exists, calling it from an early attributeChangedCallback will throw. The usual fix is a small _initialized flag, or simply guarding render() to no-op until connectedCallback has run once. Also note that static get observedAttributes() is evaluated once, before any instance exists — you cannot compute this list dynamically per-instance; every instance of the element observes the same fixed attribute list.

Shadow DOM

Shadow DOM provides encapsulation - styles and markup hidden from the main document.

Basic Shadow DOM

class ShadowCard extends HTMLElement {
  connectedCallback() {
    // Attach shadow root
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        /* Styles scoped to shadow DOM */
        .card {
          border: 1px solid #ccc;
          padding: 1rem;
          border-radius: 8px;
        }
        h2 { color: blue; }
      </style>
      <div class="card">
        <h2>Shadow Card</h2>
        <p>This is encapsulated!</p>
      </div>
    `;
  }
}

customElements.define('shadow-card', ShadowCard);

Benefits:

  • Styles don’t leak out
  • External styles don’t leak in
  • DOM structure hidden

{ mode: 'open' } versus { mode: 'closed' } is a decision worth making deliberately rather than always defaulting to open. With open mode, element.shadowRoot is accessible from outside — DevTools can inspect it, and any script on the page can reach in and query or mutate the internal markup. With closed mode, element.shadowRoot returns null to outside code, which sounds like better encapsulation but in practice mostly just breaks testing tools and makes debugging harder, without providing real security — a determined script can still often reach the shadow tree through other means, and closed mode has no bearing on style isolation, which is enforced either way. Libraries such as Lit and Stencil default to open for exactly this reason, and it is the right default for the overwhelming majority of use cases.

Shadow DOM vs Global CSS Frameworks: The Real Trade-off

This is the trade-off that catches teams off guard when they try to combine Web Components with Tailwind, Bootstrap, or any other utility-class framework. Shadow DOM’s style boundary is not selective — it blocks everything from the outer document, including your carefully configured Tailwind build. A class like class="flex items-center gap-2" inside a shadow root does nothing unless the compiled Tailwind stylesheet has also been injected inside that same shadow root, because the CSS rules that give those classes meaning live in a <style> or <link> in the main document, and shadow boundaries block inherited stylesheets by design (this is a feature, not a bug — it’s what stops a page’s global CSS from accidentally reskinning your widget).

There are three practical ways teams handle this, and the right one depends on how many custom element instances you expect on a page:

  • Duplicate the stylesheet into every shadow root. Simplest to implement, but if you have Tailwind’s full generated CSS (even purged, it can be tens of KB) and fifty instances of the same component on a page, you are potentially parsing that stylesheet fifty times. Modern browsers largely mitigate this with Constructable Stylesheets (new CSSStyleSheet() + sheet.replaceSync(css) + shadowRoot.adoptedStyleSheets = [sheet]), which lets many shadow roots share one parsed CSSStyleSheet object instead of each parsing their own copy of the text.
  • Use CSS custom properties (variables) as the theming channel instead of utility classes. Unlike class-based styling, CSS custom properties do pierce the shadow boundary by inheritance — a --brand-color defined on :root in the main document is visible inside every shadow root, because custom property inheritance follows the normal DOM inheritance chain, not the style-scoping rules. This is why component libraries built around Shadow DOM (Lit, Shoelace/Web Awesome, Ionic) expose their theming surface as CSS variables rather than expecting consumers to pass in utility classes.
  • Avoid Shadow DOM entirely for that component and render into the light DOM (this.innerHTML = ... directly on the host, no attachShadow), accepting that styles are global and letting the framework’s utility classes work exactly as they do elsewhere on the page. This sacrifices encapsulation but sidesteps the whole problem — and it’s a legitimate choice, not a cop-out, if the component only ever ships inside your own site where you already control global CSS.

None of these is strictly “correct” — the choice is a real architectural trade-off between style isolation and integration cost, and picking the wrong one for your team’s setup is the most common reason “Web Components don’t work well with our design system” complaints show up in practice.

Slots (Content Projection)

class FancyButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        button {
          background: linear-gradient(45deg, #667eea, #764ba2);
          color: white;
          border: none;
          padding: 12px 24px;
          border-radius: 8px;
          cursor: pointer;
        }
      </style>
      <button>
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('fancy-button', FancyButton);
<fancy-button>Click Me!</fancy-button>

<slot> is what lets light-DOM children (the actual markup an author writes between the element’s tags) render inside the shadow tree, similar in spirit to {children} in React or the default slot in Vue. But styling slotted content has a real limitation: the ::slotted() pseudo-element can only match the top-level slotted node directly, not its descendants. ::slotted(p) matches a <p> passed directly into the slot, but ::slotted(p span) is invalid — you cannot reach into the internals of what was slotted in from inside the shadow root’s stylesheet. This is deliberate: it would otherwise let a component’s internal styles leak arbitrarily deep into content the component doesn’t own, defeating the point of encapsulation. In practice it means slotted content styling stays fairly shallow, and anything more complex (styling nested elements inside slotted content) has to be done by the consumer of the component, in their own light-DOM stylesheet.

Named Slots

class UserProfile extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        .profile {
          display: flex;
          gap: 1rem;
          padding: 1rem;
          border: 1px solid #ddd;
        }
        .avatar { width: 64px; height: 64px; }
      </style>
      <div class="profile">
        <slot name="avatar"></slot>
        <div>
          <slot name="name"></slot>
          <slot name="bio"></slot>
        </div>
      </div>
    `;
  }
}

customElements.define('user-profile', UserProfile);
<user-profile>
  <img slot="avatar" src="avatar.jpg">
  <h2 slot="name">Alice</h2>
  <p slot="bio">Software Developer</p>
</user-profile>

HTML Templates

<template id="user-card-template">
  <style>
    .card {
      border: 1px solid #ccc;
      padding: 1rem;
      margin: 0.5rem;
    }
  </style>
  <div class="card">
    <h3></h3>
    <p></p>
  </div>
</template>

<script>
class UserCard extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('user-card-template');
    const clone = template.content.cloneNode(true);
    
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(clone);
    
    shadow.querySelector('h3').textContent = this.getAttribute('name');
    shadow.querySelector('p').textContent = this.getAttribute('role');
  }
}

customElements.define('user-card', UserCard);
</script>

The reason <template> exists as its own element, rather than just hiding a <div> with display: none, comes down to a specific behavior: content inside <template> is inert. It is parsed by the HTML parser (so you get early syntax validation) but never rendered, and critically, any <img>, <script>, or <video> inside it does not load or execute until the content is cloned out via .content.cloneNode(true) and inserted into a live document. A hidden <div> does not give you this — an <img> inside a display: none div still fires a network request immediately. This matters at scale: if you’re pre-declaring templates for dozens of possible component variants on a page, using <template> avoids dozens of unnecessary eager image/script loads for variants that may never actually get instantiated.

Properties and Methods

class Counter extends HTMLElement {
  constructor() {
    super();
    this._count = 0;
    this.attachShadow({ mode: 'open' });
    // Listen on the shadow root: render() replaces the button on every update
    this.shadowRoot.addEventListener('click', (e) => {
      if (e.target.closest('button')) this.increment();
    });
  }
  
  // Getter/setter for count property
  get count() {
    return this._count;
  }
  
  set count(value) {
    this._count = value;
    this.render();
  }
  
  // Public method
  increment() {
    this.count++;
  }
  
  connectedCallback() {
    this.render();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 8px 16px; }
      </style>
      <div>
        <p>Count: ${this.count}</p>
        <button>Increment</button>
      </div>
    `;
  }
}

customElements.define('my-counter', Counter);
// Use from JavaScript
const counter = document.querySelector('my-counter');
counter.count = 10;
counter.increment();

The click listener sits on the shadow root rather than on the button for a reason: render() replaces the whole innerHTML, so a listener attached directly to the first <button> would be thrown away with it after the first click, and the counter would stop at 1. Listening on the shadow root, which survives re-renders, avoids that.

Notice that count here is a JavaScript property (accessed via counter.count = 10), not an HTML attribute (which would be written as count="10" in markup and would always be a string). This distinction — property vs. attribute — is the single most confusing part of the Custom Elements API for people coming from React or Vue, where “props” collapses both concepts into one. If you want an element that can be configured either declaratively in markup or imperatively from JavaScript, and stay in sync both ways, you need both: an observedAttributes + attributeChangedCallback pair to react to markup changes, and a getter/setter pair to react to property assignment, with each one careful not to trigger the other in an infinite loop (a common pattern is checking if (this.getAttribute('count') !== String(value)) before calling setAttribute from inside the property setter).

Events

Dispatching Custom Events

class TodoItem extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.render();
    
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      // Dispatch custom event
      this.dispatchEvent(new CustomEvent('todo-complete', {
        detail: { id: this.getAttribute('id') },
        bubbles: true,
        composed: true // Cross shadow boundary
      }));
    });
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <div>
        <span>${this.getAttribute('text')}</span>
        <button>Complete</button>
      </div>
    `;
  }
}

customElements.define('todo-item', TodoItem);
// Listen for custom event
document.addEventListener('todo-complete', (e) => {
  console.log('Todo completed:', e.detail.id);
});

The composed: true flag in this example is not optional decoration — without it, the event will not cross the shadow boundary at all, no matter what bubbles is set to. bubbles controls whether an event travels up through ancestor elements; composed controls whether it is allowed to leave the shadow root’s boundary in the first place. Forget composed: true and a listener attached to document will simply never fire, with no error or warning anywhere — it’s a silent failure that usually gets debugged by process of elimination. There’s a second-order gotcha once you do get this working: code listening at the document level sees event.target as the shadow host element (<todo-item>), not the internal <button> that was actually clicked, because of “event retargeting” — the DOM deliberately hides shadow-internal structure from listeners outside the shadow root. If you need the real original target, use event.composedPath()[0] instead of event.target.

Real-World Example: Modal Dialog

class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
    
    // Close on backdrop click
    this.shadowRoot.querySelector('.backdrop').addEventListener('click', () => {
      this.close();
    });
    
    // Close on close button
    this.shadowRoot.querySelector('.close').addEventListener('click', () => {
      this.close();
    });
  }
  
  open() {
    this.style.display = 'block';
    document.body.style.overflow = 'hidden';
  }
  
  close() {
    this.style.display = 'none';
    document.body.style.overflow = '';
    this.dispatchEvent(new Event('modal-closed'));
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
          position: fixed;
          inset: 0;
          z-index: 1000;
        }
        
        .backdrop {
          position: absolute;
          inset: 0;
          background: rgba(0, 0, 0, 0.5);
        }
        
        .modal {
          position: relative;
          max-width: 500px;
          margin: 5rem auto;
          background: white;
          border-radius: 8px;
          padding: 2rem;
        }
        
        .close {
          position: absolute;
          top: 1rem;
          right: 1rem;
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
        }
      </style>
      
      <div class="backdrop"></div>
      <div class="modal">
        <button class="close">&times;</button>
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('modal-dialog', ModalDialog);
<button onclick="document.querySelector('modal-dialog').open()">
  Open Modal
</button>

<modal-dialog>
  <h2>Modal Title</h2>
  <p>Modal content goes here...</p>
</modal-dialog>

Treat this as a starting point for the structure of a modal, not a production-ready component — it is missing the accessibility work that a real modal needs and that is easy to skip when you’re focused on getting Shadow DOM and slots working. Specifically: there is no focus trap (keyboard users can Tab out of the modal into the page behind it), no aria-modal="true" or role="dialog" on the modal container, no handling for the Escape key, and no return-focus-to-trigger behavior when the modal closes. It’s also worth knowing that the native <dialog> element (with .showModal()) now handles focus trapping, the top-layer stacking, and Escape-to-close natively in every modern browser, and is frequently the better choice today over hand-rolling modal behavior in a custom element — you’d typically only still build a custom <modal-dialog> wrapper around <dialog> for consistent styling and API across a design system, not to reimplement the behavior <dialog> already gives you for free.

Tag names, repeated connections and teardown

Tag names need a hyphen

// Valid: contains a hyphen
customElements.define('user-profile', UserProfile);
customElements.define('todo-list', TodoList);

// Throws a SyntaxError DOMException
customElements.define('userprofile', UserProfile);

The spec requires a hyphen in every custom element tag name, specifically so the HTML parser can always tell a custom element apart from a future native HTML element the standards body might add later. customElements.define('userprofile', ...) throws at runtime, not a lint warning. Defining the same name twice also throws (NotSupportedError), which shows up when a component library ends up bundled twice on one page, or when a hot-reloading dev server re-runs the module; guard with if (!customElements.get('user-profile')) in code that can load more than once.

connectedCallback can run more than once

connectedCallback fires every time the element is inserted, and moving an element (list.append(item) on an item that is already in the DOM, or sorting a list by re-appending its children) disconnects and reconnects it. Several examples in this post call this.attachShadow() inside connectedCallback for brevity; the second connection then throws NotSupportedError because the element already has a shadow root. Attach the shadow root in the constructor, or check if (!this.shadowRoot) first, and keep one-time setup (listeners on the shadow root, initial rendering) behind a similar guard.

Tear down what connectedCallback started

class LiveClock extends HTMLElement {
  connectedCallback() {
    this.timer = setInterval(() => this.render(), 1000);
    this.onResize = () => this.render();
    window.addEventListener('resize', this.onResize);
  }
  
  disconnectedCallback() {
    clearInterval(this.timer);
    window.removeEventListener('resize', this.onResize);
  }

  render() {
    this.textContent = new Date().toLocaleTimeString();
  }
}

Listeners attached to the element itself or to its own shadow root go away with it, so they need no cleanup. What leaks is anything that references the element from the outside: a setInterval, a listener on window or document, a ResizeObserver, or a subscription to an external store. There’s no framework runtime tearing these down for you, so if disconnectedCallback does not undo them, they keep running and keep the element reachable after it has been removed from the DOM, a slow leak that is easy to miss in a demo and painful in a long-running single-page app that mounts and unmounts the same widget many times.

Framework Integration

Framework interop is where the “just works everywhere” pitch runs into real friction, and it’s worth understanding why rather than treating the workarounds as folklore.

React

function App() {
  return (
    <div>
      <user-card name="Alice" role="Developer"></user-card>
    </div>
  );
}

For a string prop like name above, this works fine — JSX writes it as an HTML attribute, and getAttribute('name') picks it up. The friction shows up the moment you need to pass something that isn’t a string. In React versions before 19, JSX has no way to distinguish “set this as a DOM property” from “set this as an HTML attribute,” and for unrecognized tag names it defaults to attribute-like handling — which means passing an array or a callback function as a JSX prop on a custom element largely does not work; the array gets stringified into something useless like "[object Object]", and event handlers written as onTodoComplete={...} never fire because React has no idea the element dispatches a todo-complete CustomEvent — React’s synthetic event system only understands its own known DOM events. The traditional workaround is to drop to an imperative escape hatch: grab a ref, then in a useEffect, set the property directly (ref.current.items = myArray) and attach the event listener manually (ref.current.addEventListener('todo-complete', handler)), cleaning it up on unmount. React 19 narrows this gap by adding proper support for setting custom element properties and listening for custom events more directly, but if you need to support React 17/18 consumers of a component library, the imperative ref + addEventListener pattern is still the reliable approach.

Vue

<template>
  <user-card name="Alice" role="Developer"></user-card>
</template>

Vue’s story here is meaningfully smoother than React’s, which is a real reason some design-system teams prefer shipping Web Components to Vue-heavy consumers over React-heavy ones. Vue’s compiler checks, for a given binding, whether the target is a known DOM property on the element and will set it as a property rather than an attribute when appropriate, and v-on bindings can listen for arbitrary custom event names directly (@todo-complete="handler") without any special-casing. You do need to tell Vue’s compiler that a given tag is a custom element rather than an unrecognized Vue component, via compilerOptions.isCustomElement (or isCustomElement in the Vite plugin config), otherwise Vue will emit a “failed to resolve component” warning and try to treat your custom element as a missing Vue component.

Angular

import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

CUSTOM_ELEMENTS_SCHEMA exists because Angular’s template compiler validates every attribute and tag against its known component/directive registry at compile time, and without this schema it will hard-error on any tag it doesn’t recognize, rather than just warning. Once declared, Angular’s property binding syntax ([items]="myArray") works correctly for complex data because — like Vue — Angular checks whether the bound name is an actual property on the underlying DOM element and assigns it directly, rather than always stringifying to an attribute.

Common Pitfalls and Troubleshooting

FOUC-style flash of unstyled/undefined content. Because customElements.define() runs as JavaScript and JavaScript loads and executes after the browser has already started parsing HTML, there’s a real window where <user-card> exists in the DOM but hasn’t been “upgraded” into its class yet — during that window it renders as an unstyled inline element with no shadow root, and any light-DOM fallback content briefly shows through. The CSS fix is the :not(:defined) pseudo-class, typically paired with visibility: hidden or a skeleton style, so undefined elements are hidden until they upgrade:

my-card:not(:defined) {
  visibility: hidden;
}

If you need to run JavaScript only after an element is upgraded (for example, to avoid calling a method on it before its class methods exist), customElements.whenDefined('my-card') returns a promise that resolves once that tag has been registered — useful when a component library loads its element definitions asynchronously, or when the elements are defined by a script tag deferred to the end of the document.

Server-rendered Shadow DOM and hydration timing. If you server-render pages containing custom elements (common in Astro, Next.js, or any SSR setup), the HTML the server sends down has no shadow root at all — Shadow DOM is a client-side, JavaScript-created construct, so a crawler or a user with JavaScript disabled sees only the light-DOM fallback content, and there’s a flash between first paint and the moment the client-side script upgrades each element and attaches its shadow root. Declarative Shadow DOM (<template shadowrootmode="open">) addresses this directly by letting the server embed the shadow root’s content in the initial HTML, so the browser attaches it during parsing rather than waiting for JavaScript — worth knowing about specifically if you’ve noticed a layout shift or unstyled flash on the very first render of a Web Components-based widget on a server-rendered page.

When to stop writing raw custom elements

The APIs in this post are enough for a handful of leaf components — a date badge, a copy button, a modal. The point to reconsider is when you catch yourself re-rendering innerHTML on every attribute change, hand-diffing lists, or writing the same attribute-to-property conversion code in every class. That is the work a small library such as Lit does for you, while still producing standard custom elements that any framework can use. For the underlying DOM APIs, the JavaScript DOM series and MDN’s Web Components guide are the references.


Frequently Asked Questions (FAQ)

Q. When should I reach for Web Components instead of a framework component?

A. Prefer Web Components when the component needs to be consumed across framework boundaries — a design system shared between a React app and a Vue app, or a widget embedded on third-party sites you don’t control. Inside a single app on a single framework, a native framework component is almost always less code and easier to maintain, because you don’t have to hand-build the reactive rendering, prop typing, and event handling that the framework already gives you.

Q. Why did my custom element’s styles leak into the rest of the page?

A. Most likely you rendered with this.innerHTML = ... directly on the host element instead of calling this.attachShadow({ mode: 'open' }) first and rendering into shadow.innerHTML. Without a shadow root, any <style> tag you inject is just a normal global stylesheet with page-wide scope — Shadow DOM is what actually creates the style boundary.