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-Pattern | Consequence | Fix |
|---|---|---|
| innerHTML for every render | Performance issues, event listener leaks | DOM diffing or targeted updates |
| No Shadow DOM | Style leakage, collision with page styles | Always use Shadow DOM for encapsulation |
Not using composed: true | Events don’t cross shadow boundaries | Set composed for events consumers need |
| Heavy framework inside components | Bundle bloat, defeats purpose | Vanilla JS or Lit for internals |
| No observedAttributes | Attributes change but component doesn’t update | Declare 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.