Web Component Development: Create reusable custom HTML elements
Web Components let you create custom HTML elements with encapsulated styles and behavior, usable in any project regardless of framework — React, Vue, or plain HTML. They're native browser APIs: no build step, no framework runtime to bundle, and components you write are naturally reusable across projects.
If you've maintained projects across multiple tech stacks, you know the pain: the same "button" is one component in React, another in Vue, and hand-rolled HTML in a server-rendered page. Web Components take a different approach — the component is part of the browser itself, and anyone can use it directly.
Three Core Technologies
- Custom Elements — Define new HTML elements
- Shadow DOM — Style and behavior encapsulation
- HTML Templates — Reusable HTML structures
The Custom Elements lifecycle
Custom elements have four standard lifecycle callbacks; understanding them is the key to robust components:
| Callback | When it fires | Typical use |
|---|---|---|
connectedCallback() |
Element inserted into the document | Bind events, fetch data |
disconnectedCallback() |
Element removed from the document | Unbind events, clear timers |
attributeChangedCallback() |
Observed attribute changes | Re-render on attribute updates |
adoptedCallback() |
Element moved to a new document | Rare; cross-document moves |
Note that connectedCallback() can fire multiple times (an element can be moved around in the DOM), so bind events there and unbind them in disconnectedCallback() — otherwise you'll double-bind.
Creating a Web Component
// Custom button component
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['variant', 'disabled'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const variant = this.getAttribute('variant') || 'primary';
const disabled = this.hasAttribute('disabled') ? 'disabled' : '';
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}
button.primary {
background: #4F46E5;
color: white;
}
button.primary:hover {
background: #4338CA;
}
button.outline {
background: transparent;
border: 2px solid #4F46E5;
color: #4F46E5;
}
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
</style>
<button class="${variant}" ${disabled}>
<slot></slot>
</button>
`;
// Event handling
this.shadowRoot.querySelector('button')
.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('my-click', {
detail: { id: this.id }
}));
});
}
}
// Register the custom element
customElements.define('my-button', MyButton);
Using the custom element
<my-button variant="primary" id="submit-btn">Submit</my-button>
<my-button variant="outline">Cancel</my-button>
<my-button disabled>Disabled</my-button>
<script>
document.querySelector('#submit-btn')
.addEventListener('my-click', (e) => {
console.log('Button clicked:', e.detail.id);
});
</script>
Shadow DOM and style isolation
The <style> inside MyButton lives in the shadowRoot — that's the power of Shadow DOM: component styles don't leak out, and outside styles don't leak in. If you globally defined button { border-radius: 0 }, it won't affect the button inside <my-button>.
A concrete illustration: put a plain button and two <my-counter> elements on the same page. Even if global styles turn buttons red, the counter's plus/minus buttons keep their own white rounded style — because they live in the shadow tree. To tweak internals from outside, you're limited to CSS custom properties (--color) or the ::part() pseudo-element, which is itself a form of "controlled openness."
HTML templates and slots
Content inside a <template> tag isn't rendered until referenced — great for component skeletons; <slot> is the placeholder that lets consumers fill in content. That's what <slot></slot> inside <my-button> does:
<my-button>Submit</my-button>
<my-button>Cancel</my-button>
Both buttons share the same style, but the text is decided by whoever uses them — "structure reused, content open."
A More Complex Example: A Counter Component
class Counter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.count = 0;
}
connectedCallback() {
this.render();
this.shadowRoot.querySelector('#inc')
.addEventListener('click', () => this.update(1));
this.shadowRoot.querySelector('#dec')
.addEventListener('click', () => this.update(-1));
}
update(amount) {
this.count += amount;
this.shadowRoot.querySelector('#value').textContent = this.count;
this.dispatchEvent(new CustomEvent('count-change', {
detail: { count: this.count }
}));
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 12px; }
button {
width: 36px; height: 36px;
border: 1px solid #ccc;
border-radius: 50%;
background: white;
cursor: pointer;
font-size: 18px;
}
span { min-width: 30px; text-align: center; font-size: 20px; }
</style>
<button id="dec">-</button>
<span id="value">0</span>
<button id="inc">+</button>
`;
}
}
customElements.define('my-counter', Counter);
<my-counter></my-counter>
<my-counter></my-counter>
Advantages at a glance
| Feature | Why it matters |
|---|---|
| Framework-agnostic | Works in any project |
| Style isolation | Shadow DOM prevents style clashes |
| Native support | No build step, no polyfill in modern browsers |
| Reusable | Write once, use everywhere |
| Encapsulated | HTML, CSS, and JS packaged in one file |
A Design System Scenario
Say your company runs three products: an admin console in React, a marketing site in Vue, and landing pages in raw rendered HTML. Previously, a unified button style meant maintaining the same component in three stacks. Now you wrap <my-button>, <my-counter>, and friends into Web Components and publish them as an internal npm package — every project just loads a script tag:
<script type="module" src="/components/my-button.js"></script>
<my-button variant="primary">Submit order</my-button>
Change the style in one place and the whole site updates; bump the package version to upgrade everywhere. That's the core reason Web Components thrive in large organizations — components stop being "something of a framework" and become shared infrastructure.
If your team is planning to unify components across endpoints, pilot with a high-frequency component first (a button or dialog): swap out each project's implementation, run a couple of iterations, then decide whether to roll out everywhere. Low risk, measurable payoff.
When to Use Web Components
- Shared component libraries across frameworks: when React, Vue, and Angular coexist, a Web Component shared UI layer lets every framework just drop in
<my-card>-style tags; - Long-lived components: frameworks churn, but browser APIs are stable — components invested in Web Components are still usable a decade from now;
- Progressive enhancement: embedding interactive components in server-rendered pages without rebuilding the whole thing as an SPA.
Less ideal: components that lean heavily on framework ecosystems (e.g., needing React Context), or full-page content that requires SSR for SEO — there, Web Components' "runtime rendering" is a downside.
Compatibility and Best Practices
Modern browsers (Chrome, Edge, Firefox, Safari) support Web Components natively — no polyfill needed. A few things to watch:
- Once registered with
customElements.define(), the same name can't be registered twice; watch for naming collisions in component libraries; - When rendering many components synchronously, consider async work in
connectedCallbackor batched updates viarequestAnimationFrameto avoid first-paint jank; - If you add
observedAttributes, keep the render logic inattributeChangedCallbackin sync — otherwise attributes change but the UI doesn't; - For form-like components, remember
formAssociatedto integrate with native forms, or the value won't be submitted.
FAQ
Do Web Components work with SEO? Content rendered inside Shadow DOM is being indexed increasingly well by search engines, but for critical copy consider emitting it inside the element or using declarative shadow DOM server-side, rather than relying purely on runtime rendering.
Can I support older browsers? IE has left the stage; if you still must support legacy Edge or very old engines, add the @webcomponents/webcomponentsjs polyfill, but most modern sites don't need it.
Do Web Components clash with Vue/React components? No. Framework components are "a language inside a framework"; Web Components are "native browser elements." They nest and mix freely, which is exactly why teams build framework-agnostic UI libraries.
16IDC Takeaway
Web Components are the best cross-framework component encapsulation approach. They work in any project. For design systems and UI component libraries, Web Components outlast framework-specific components. If your team maintains projects across different tech stacks, Web Components are the write-once-run-anywhere solution.