Production engineering guide for web components patterns covering patterns, implementation strategies, and operational best practices.
Web Components Patterns is a collection of proven, repeatable strategies for building modular, reusable, and self-contained UI elements using native web technologies. It matters when you need to share components across multiple frameworks—React, Vue, Angular, and plain HTML—without incurring framework-specific overhead, and when components must work in isolation, in any hosting environment: from static sites to complex SPAs, from micro-frontends to embedded widgets.
A Web Component is a self-contained unit of UI built with the standards: HTML templates, custom elements, and CSS shadow DOM. The core pattern is to define a <my-button> element that encapsulates markup, styles, and behavior, and then use it anywhere without side effects.
<my-button label="Click me" variant="primary" size="large"></my-button>
To create the component, define a class extending HTMLElement:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
font-family: sans-serif;
}
button {
padding: 0.5em 1em;
border: none;
border-radius: 4px;
background: var(--bg, #007acc);
color: white;
font-size: var(--size, 1rem);
cursor: pointer;
}
button:hover {
background: #005f99;
}
</style>
<button id="btn"></button>
`;
}
connectedCallback() {
const btn = this.shadowRoot.getElementById('btn');
btn.textContent = this.getAttribute('label') || 'Button';
btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('click', { bubbles: true }));
});
}
static get observedAttributes() {
return ['label', 'variant', 'size'];
}
attributeChangedCallback(name, oldValue, newValue) {
const btn = this.shadowRoot.getElementById('btn');
const root = this.shadowRoot.host;
switch (name) {
case 'label':
btn.textContent = newValue;
break;
case 'variant':
root.style.setProperty('--bg', this.variantColor(newValue));
break;
case 'size':
root.style.setProperty('--size', this.sizeValue(newValue));
break;
}
}
variantColor(variant) {
const map = {
primary: '#007acc',
secondary: '#6c757d',
success: '#28a745',
danger: '#dc3545'
};
return map[variant] || '#007acc';
}
sizeValue(size) {
const map = {
small: '0.8rem',
medium: '1rem',
large: '1.25rem'
};
return map[size] || '1rem';
}
}
customElements.define('my-button', MyButton);
The key insight is that shadowRoot provides true encapsulation: styles do not leak in or out, and DOM structure is isolated. The connectedCallback runs when the element is inserted into the DOM, and attributeChangedCallback responds to changes in attributes. Use observedAttributes to declare which attributes trigger updates.
Shadow DOM is the heart of a Web Component. It creates a boundary between the component’s internal DOM and the document outside. This boundary prevents global CSS from affecting the component and ensures the component’s styles do not bleed into the host.
When a component is rendered inside a <my-card> that has a background-color set to #f0f0f0, the card’s styles do not affect the button’s internal layout unless explicitly configured.
To control how the shadow DOM interacts with the host, use the mode option in attachShadow:
mode: 'open' – the shadow root is accessible via element.shadowRoot.mode: 'closed' – element.shadowRoot is not directly accessible; only the component’s API is exposed.Use open mode when you want developers to style or manipulate the component from outside the component’s code. Use closed when you want to enforce a strict API and hide internals.
The most common failure is forgetting to call render() or update() after setting properties. For example:
const btn = document.querySelector('my-button');
btn.setAttribute('label', 'New Label');
// No automatic update: shadow DOM is static unless explicitly updated
To fix this, define a render() method and call it in attributeChangedCallback and connectedCallback.
render() {
const btn = this.shadowRoot.getElementById('btn');
btn.textContent = this.getAttribute('label') || 'Button';
this.shadowRoot.host.style.setProperty('--bg', this.variantColor(this.getAttribute('variant')));
}
Web Components communicate via properties and events. Properties are set via attributes or JavaScript, and events are dispatched for user interactions.
To expose a property, use get and set accessors on the class:
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
Set properties directly on the element:
const btn = document.querySelector('my-button');
btn.label = 'Submit';
btn.variant = 'success';
btn.disabled = true;
For more complex data, use JSON.parse on attribute values:
get config() {
return JSON.parse(this.getAttribute('config') || '{}');
}
set config(value) {
this.setAttribute('config', JSON.stringify(value));
}
Events are dispatched with dispatchEvent(new CustomEvent(...)). Use bubbles: true so the event propagates up the DOM tree.
btn.addEventListener('click', (e) => {
console.log('Button clicked:', e.detail);
});
The detail property is the payload, and it can contain any structured data.
Web Components evolve over time. They are not static. To manage state, store it in this or use Map or WeakMap for private data.
class MyForm extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._state = new Map();
this._state.set('submitted', false);
this._state.set('errors', []);
}
get submitted() {
return this._state.get('submitted');
}
set submitted(value) {
this._state.set('submitted', value);
this.render();
}
render() {
const form = this.shadowRoot.querySelector('form');
form.classList.toggle('submitted', this.submitted);
}
connectedCallback() {
this.shadowRoot.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
this.submitted = true;
this.dispatchEvent(new CustomEvent('submit', { detail: { data: this.getData() } }));
});
}
getData() {
return Array.from(this.shadowRoot.querySelectorAll('input'))
.reduce((acc, el) => {
acc[el.name] = el.value;
return acc;
}, {});
}
}
The connectedCallback runs once when the element is attached. disconnectedCallback runs when it is removed.
disconnectedCallback() {
this.shadowRoot.querySelector('form').removeEventListener('submit', this.submitHandler);
this._state.clear();
}
This pattern prevents memory leaks and ensures clean teardown.
To allow rich composition, use <slot> elements inside the shadow DOM to define where content can be inserted.
<!-- Inside shadowRoot -->
<slot name="leading"></slot>
<button>
<slot>Default Label</slot>
</button>
<slot name="trailing"></slot>
Then use the component like:
<my-button>
<span slot="leading">▶</span>
<span>Save Draft</span>
<span slot="trailing">💾</span>
</my-button>
Use ::slotted() in CSS to style slotted content:
::slotted(span) {
font-weight: 500;
margin: 0 0.25em;
}
For global theming, define CSS custom properties (variables) that can be set on the host element:
:host {
--button-bg: #007acc;
--button-font-size: 1rem;
--button-padding: 0.5em 1em;
}
:host([variant="large"]) {
--button-font-size: 1.25rem;
--button-padding: 0.75em 1.5em;
}
These variables can be set directly on the element:
document.querySelector('my-button').style.setProperty('--button-bg', '#ff6b35');
Web Components are framework-agnostic. To use a component in React, Vue, or Angular, import the JavaScript and include it in the bundle.
In React:
import { MyButton } from './my-button.js';
function App() {
return (
<div>
<MyButton label="Submit" variant="primary" size="large" />
</div>
);
}
In Vue:
<template>
<div>
<my-button label="Save" variant="success" size="small" />
</div>
</template>
<script>
import { MyButton } from './my-button.js';
export default {
components: { MyButton }
};
</script>
Framework-specific bindings are not required. The component works as a plain <my-button> element.
When embedding in a framework, the component receives attributes and dispatches events, but framework-specific state (like React’s useState) is independent.
customElements.define: The component is defined but never registered. The element remains as a plain <my-button> without behavior.connectedCallback runs, but render() is not called. The component appears but does not react to attribute changes.variant is set, but attributeChangedCallback is not triggered because observedAttributes is missing or misspelled.:host, but the host element does not have the correct display or font-size to allow cascading.CustomEvent is dispatched, but bubbles: false is used, so it does not propagate to the document.slot is defined, but content is inserted into the wrong slot or no slot attribute is used.connectedCallback: The component is created but not initialized until connectedCallback is explicitly called.To distribute a Web Component, bundle it with a tool like esbuild, rollup, or webpack.
esbuild src/my-button.js --bundle --format=esm --outfile=dist/my-button.js
Use package.json to define module entry points:
{
"name": "my-button",
"version": "1.0.0",
"main": "dist/my-button.js",
"module": "dist/my-button.js",
"types": "dist/my-button.d.ts",
"exports": {
".": {
"import": "./dist/my-button.js",
"require": "./dist/my-button.js"
}
}
}
Publish to npm:
npm publish
Consumers install and use:
npm install my-button
import { MyButton } from 'my-button';
customElements.define('my-button', MyButton);
For performance, load Web Components on demand.
async function loadComponent(name) {
const module = await import(`./components/${name}.js`);
customElements.define(name, module.default);
}
// Use when needed
loadComponent('my-dialog').then(() => {
const dialog = document.createElement('my-dialog');
document.body.appendChild(dialog);
});
Or load multiple components in a batch:
const components = ['my-button', 'my-card', 'my-form'];
Promise.all(components.map(loadComponent)).then(() => {
console.log('All components loaded');
});
Use IntersectionObserver to lazy-load components when they enter the viewport.
This completes the core set of patterns for building robust, reusable, and performant Web Components.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy