ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Web Component Architecture

Build reusable, framework-agnostic UI components using native Web Components. Covers Custom Elements, Shadow DOM, HTML templates, slots, lifecycle callbacks, and the patterns that make Web Components work alongside React, Vue, and Angular.

Web Components are browser-native building blocks for creating reusable UI elements. Unlike React components or Vue components, Web Components work in every framework and none — they are part of the web platform itself. This makes them ideal for design systems, shared component libraries, and micro-frontend architectures.


Core APIs

Custom Elements:  Define new HTML tags with custom behavior
Shadow DOM:       Encapsulated styles and markup (no CSS leakage)
HTML Templates:   Reusable markup fragments (<template> tag)
Slots:            Composition points for consumer content

Custom Element

class GarnetButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'size', 'disabled'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button')
      .addEventListener('click', this._handleClick.bind(this));
  }
  
  disconnectedCallback() {
    this.shadowRoot.querySelector('button')
      .removeEventListener('click', this._handleClick);
  }
  
  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
  
  get variant() { return this.getAttribute('variant') || 'primary'; }
  get size() { return this.getAttribute('size') || 'medium'; }
  
  _handleClick(e) {
    if (this.hasAttribute('disabled')) {
      e.preventDefault();
      return;
    }
    this.dispatchEvent(new CustomEvent('garnet-click', {
      bubbles: true,
      composed: true,  // Crosses shadow DOM boundary
      detail: { source: this }
    }));
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }
        button {
          padding: ${this.size === 'small' ? '6px 12px' : '10px 20px'};
          border: none;
          border-radius: 6px;
          font-size: ${this.size === 'small' ? '13px' : '15px'};
          cursor: pointer;
          background: ${this.variant === 'primary' ? '#6366f1' : 'transparent'};
          color: ${this.variant === 'primary' ? 'white' : '#6366f1'};
          border: ${this.variant === 'outline' ? '2px solid #6366f1' : 'none'};
          transition: all 0.2s ease;
        }
        button:hover {
          filter: brightness(1.1);
          transform: translateY(-1px);
        }
      </style>
      <button part="button">
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('garnet-button', GarnetButton);

Usage

<!-- Works in any framework or plain HTML -->
<garnet-button variant="primary" size="medium">
  Submit Order
</garnet-button>

<garnet-button variant="outline" disabled>
  Processing...
</garnet-button>

Slots (Composition)

class GarnetCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #e2e8f0;
          border-radius: 12px;
          overflow: hidden;
        }
        .header { padding: 16px 20px; background: #f8fafc; }
        .body { padding: 20px; }
        .footer { padding: 12px 20px; border-top: 1px solid #e2e8f0; }
      </style>
      <div class="card">
        <div class="header"><slot name="header"></slot></div>
        <div class="body"><slot></slot></div>
        <div class="footer"><slot name="footer"></slot></div>
      </div>
    `;
  }
}
customElements.define('garnet-card', GarnetCard);
<garnet-card>
  <h3 slot="header">Order #1234</h3>
  <p>Order details go here in the default slot.</p>
  <garnet-button slot="footer" variant="primary">Confirm</garnet-button>
</garnet-card>

Framework Interop

// React interop
function App() {
  const handleClick = (e) => console.log('Clicked!', e.detail);
  
  return (
    <garnet-button 
      variant="primary" 
      onGarnet-click={handleClick}
    >
      React + Web Component
    </garnet-button>
  );
}

// Vue interop
<template>
  <garnet-button variant="outline" @garnet-click="handleClick">
    Vue + Web Component
  </garnet-button>
</template>

// Angular interop (add CUSTOM_ELEMENTS_SCHEMA)
@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

Anti-Patterns

Anti-PatternConsequenceFix
innerHTML for every renderPerformance issues, event listener leaksDOM diffing or targeted updates
No Shadow DOMStyle leakage, collision with page stylesAlways use Shadow DOM for encapsulation
Not using composed: trueEvents don’t cross shadow boundariesSet composed for events consumers need
Heavy framework inside componentsBundle bloat, defeats purposeVanilla JS or Lit for internals
No observedAttributesAttributes change but component doesn’t updateDeclare all reactive attributes

Web Components are the universal building block for the web platform. They work today, they will work tomorrow, and they work in every framework — or none.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →