DNA📄 HTMLWeb Components & Shadow DOM
ðŸĶ–DinosaurHTMLWeb ComponentsShadow DOM

Web Components & Shadow DOM

Web Components are the browser's native component model — no build step, no framework, no version conflicts. Understanding them is understanding the platform itself.

Web Components & Shadow DOM

Every component framework — React, Vue, Angular, Svelte — is an abstraction over what the browser can now do natively. Web Components aren't a replacement for these frameworks, but they fill a gap nothing else can: framework-agnostic, encapsulated, standards-based UI primitives that work everywhere, forever.

Custom Elements API

Custom elements let you define new HTML tags with their own behavior:

class UserCard extends HTMLElement {
  static observedAttributes = ['name', 'role'];
 
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    this.render();
  }
 
  disconnectedCallback() {
    // Cleanup: remove event listeners, cancel timers, disconnect observers
  }
 
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }
 
  adoptedCallback() {
    // Called when element is moved to a new document (e.g., iframe adoption)
  }
 
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; padding: 1rem; border: 1px solid #e2e8f0; }
        :host([highlighted]) { border-color: #3b82f6; }
        .name { font-weight: bold; font-size: 1.125rem; }
        .role { color: #64748b; }
      </style>
      <div class="name">${this.getAttribute('name') || 'Unknown'}</div>
      <div class="role">${this.getAttribute('role') || 'Member'}</div>
      <slot></slot>
    `;
  }
}
 
customElements.define('user-card', UserCard);
<user-card name="Sarah Chen" role="Staff Engineer">
  <p>Frontend platform team</p>
</user-card>

Lifecycle Callbacks

CallbackWhen It FiresCommon Use
constructor()Element created (not yet in DOM)Setup shadow root, initial state, event bindings
connectedCallback()Element added to DOMRender, fetch data, start observers
disconnectedCallback()Element removed from DOMCleanup listeners, cancel fetches, disconnect observers
attributeChangedCallback(name, old, new)Observed attribute changesRe-render, update state
adoptedCallback()Element moved between documentsRare; re-initialize document-specific logic

Critical detail: Only attributes listed in static observedAttributes trigger attributeChangedCallback. This is a deliberate performance optimization — the browser doesn't watch all attributes.

Registration Rules

customElements.define('user-card', UserCard);
 
// Names MUST contain a hyphen (distinguishes from native elements)
customElements.define('card', Card);       // ❌ Error: no hyphen
customElements.define('user-card', Card);  // ✅ Valid
 
// Check if already defined
customElements.get('user-card');           // Returns constructor or undefined
 
// Wait for definition
await customElements.whenDefined('user-card');

Shadow DOM: True Encapsulation

Shadow DOM creates an encapsulated DOM subtree. Styles don't leak in or out:

class StyledButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        button { background: #3b82f6; color: white; border: none;
                 padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; }
        button:hover { background: #2563eb; }
      </style>
      <button><slot></slot></button>
    `;
  }
}
customElements.define('styled-button', StyledButton);
<style>button { background: red; }</style>         <!-- Global styles -->
<styled-button>Click Me</styled-button>             <!-- Still blue — shadow DOM isolates -->
<button>Regular Button</button>                     <!-- Red from global styles -->

Shadow DOM Modes

this.attachShadow({ mode: 'open' });
// element.shadowRoot is accessible from outside
 
this.attachShadow({ mode: 'closed' });
// element.shadowRoot returns null — truly private
// Used by browser internals (video controls, date picker)

Practical advice: Almost always use open. Closed mode prevents devtools inspection and testing — the security benefit is minimal since JavaScript can still access internals through other means.

Slots: Composition in Shadow DOM

Slots allow consumers to inject content into your component:

class AppModal extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
        .modal { background: white; border-radius: 8px; padding: 2rem;
                 max-width: 500px; margin: 10vh auto; }
      </style>
      <div class="overlay">
        <div class="modal" role="dialog" aria-modal="true">
          <slot name="title"><h2>Dialog</h2></slot>
          <slot></slot>
          <slot name="actions"></slot>
        </div>
      </div>
    `;
  }
}
customElements.define('app-modal', AppModal);
<app-modal>
  <h2 slot="title">Confirm Delete</h2>
  <p>This action cannot be undone.</p>
  <div slot="actions">
    <button>Cancel</button>
    <button>Delete</button>
  </div>
</app-modal>

::slotted — Styling Slotted Content

The ::slotted() pseudo-element styles content projected through slots, but only at the top level:

/* Inside shadow DOM styles */
::slotted(h2) {
  color: #1e293b;
  margin: 0;
}
 
::slotted(p) {
  color: #64748b;
}
 
/* ❌ Does NOT work — ::slotted only targets direct children */
::slotted(div > p) {
  color: red;
}

::part — Exposing Styling Hooks

Component authors expose styling hooks via part; consumers use ::part() to style them:

<!-- Inside shadow DOM -->
<button part="trigger"><slot></slot></button>
/* Consumer stylesheet — outside shadow DOM */
styled-button::part(trigger) { background: #10b981; font-weight: bold; }
styled-button::part(trigger):hover { background: #059669; }

This is the controlled styling API — no accidental leaking.

CSS Custom Properties Cross Shadow Boundaries

CSS custom properties (variables) are the one styling mechanism that penetrates shadow DOM:

/* Host page defines tokens */
:root { --brand-color: #3b82f6; --border-radius: 0.5rem; }
 
/* Inside shadow DOM — inherited custom properties work */
button { background: var(--brand-color, #6366f1); border-radius: var(--border-radius, 0.25rem); }

This makes CSS custom properties the theming API for Web Components — define a design token system once, and all shadow DOMs inherit it.

HTML Templates

<template> defines inert HTML — parsed but not rendered until cloned. No images load, no scripts execute:

<template id="card-template">
  <div class="card">
    <h3 class="title"></h3>
    <p class="description"></p>
  </div>
</template>
const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.title').textContent = 'Web Components';
document.body.appendChild(clone);

Declarative Shadow DOM

Server-side rendering Web Components requires declarative shadow DOM:

<user-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; }
      .name { font-weight: bold; }
    </style>
    <div class="name">Sarah Chen</div>
    <slot></slot>
  </template>
  <p>Frontend platform team</p>
</user-card>

The browser attaches the shadow root during HTML parsing — no JavaScript needed for the initial render. The custom element's JavaScript can then hydrate and add interactivity.

Shadow DOM & Accessibility

Shadow DOM respects the accessibility tree — both slotted and shadow content are visible to assistive technology.

Gotchas:

  • aria-labelledby and aria-describedby cannot reference IDs across shadow boundaries
  • Use aria-label instead, or ensure referenced IDs are in the same shadow root
  • Focus delegation: this.attachShadow({ mode: 'open', delegatesFocus: true }) automatically focuses the first focusable element in the shadow root

Web Components vs Frameworks

AspectWeb ComponentsReact/Vue/Angular
StandardW3C spec, no build stepLibrary/framework-specific
EncapsulationReal (shadow DOM)Simulated (CSS modules, scoped)
ReactivityManual (attributeChangedCallback)Built-in (virtual DOM, signals, proxies)
SSRDeclarative shadow DOMFramework SSR solutions
EcosystemGrowingMassive
State managementDIY or LitRedux, Vuex, signals, etc.
Learning curveDOM APIs directlyFramework abstractions
Best forDesign systems, micro-frontends, framework-agnostic widgetsApplication development

When to Use Web Components

  1. Design systems — Components that must work across React, Vue, Angular, and vanilla JS projects
  2. Micro-frontends — Isolated widgets from different teams/stacks
  3. Third-party embeds — Widgets that must not be affected by host page styles
  4. Progressive enhancement — Custom elements that enhance native HTML

Lit Framework Overview

Lit (~5KB) is the go-to library for building Web Components with ergonomic reactivity:

import { LitElement, html, css } from 'lit';
 
class UserCard extends LitElement {
  static properties = {
    name: { type: String },
    expanded: { type: Boolean, reflect: true },
  };
 
  static styles = css`
    :host { display: block; padding: 1rem; border: 1px solid #e2e8f0; }
    .details { display: none; }
    :host([expanded]) .details { display: block; }
  `;
 
  render() {
    return html`
      <div class="name">${this.name}</div>
      <button @click=${() => this.expanded = !this.expanded}>
        ${this.expanded ? 'Hide' : 'Show'} Details
      </button>
      <div class="details"><slot></slot></div>
    `;
  }
}
customElements.define('user-card', UserCard);

Lit adds reactive properties (auto-re-render on change), efficient tagged template rendering, and decorators — while staying close to the platform.

Interview Mental Model

Web Components Stack
├── Custom Elements  → Define new HTML tags (customElements.define)
├── Shadow DOM       → Style and DOM encapsulation (attachShadow)
├── HTML Templates   → Inert, reusable markup (<template>)
├── Slots            → Composition / content projection (<slot>)
└── CSS Parts        → Controlled external styling (::part)
 
When to reach for Web Components:
  "Does this need to work in ANY framework or NO framework?"
  → Yes: Web Components
  → No:  Use your framework's component model