Web Components — Build Framework-Free Reusable UI Elements
How to build custom HTML elements with Web Components, Shadow DOM, and HTML templates. Covers practical examples, styling, slots, lifecycle hooks, and when to use them over frameworks.
React components only work in React. Vue components only work in Vue. Svelte components only work in Svelte. You've probably noticed the problem.
Web Components are HTML's native component model. A custom element you build today works in React, Vue, Svelte, Angular, plain HTML, or whatever framework exists five years from now. No build step required. No dependencies. Just the browser.
The technology has been around since 2018, but adoption was slow because browser support was spotty and the API was verbose. Both of those problems are solved now. All modern browsers support Web Components, and the patterns have matured enough that building them isn't painful anymore.
The Three APIs
Web Components are built on three browser APIs:
- Custom Elements — define new HTML tags with JavaScript behavior
- Shadow DOM — encapsulated DOM and styles that don't leak
- HTML Templates — reusable markup fragments
Your First Custom Element
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const name = this.getAttribute("name") || "Anonymous";
const role = this.getAttribute("role") || "Member";
const avatar = this.getAttribute("avatar") || "";
this.shadowRoot.innerHTML =
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
}
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
background: #e2e8f0;
}
.name {
font-weight: 600;
font-size: 16px;
color: #1a202c;
}
.role {
font-size: 14px;
color: #718096;
}
</style>
<div class="card">
${avatar ? <img class="avatar" src="${avatar}" alt="${name}" /> : <div class="avatar"></div>}
<div>
<div class="name">${name}</div>
<div class="role">${role}</div>
</div>
</div>
;
}
}
customElements.define("user-card", UserCard);
Use it:
<user-card name="Alice Chen" role="Senior Engineer" avatar="/avatars/alice.jpg"></user-card>
<user-card name="Bob Park" role="Designer"></user-card>
That's it. No imports, no bundler, no framework. Just an HTML tag that works.
Important naming rule: custom element names must contain a hyphen. is valid. is not. This prevents conflicts with current and future HTML elements.
Shadow DOM — Style Encapsulation
The Shadow DOM is what makes Web Components genuinely useful. Styles inside the shadow root don't affect the rest of the page, and page styles don't affect the component.
// This CSS only affects elements inside this component
this.shadowRoot.innerHTML =
<style>
p { color: red; font-size: 24px; }
</style>
<p>This text is red and large</p>
;
Even if the page has p { color: blue; font-size: 12px; }, the component's paragraph stays red and large. No BEM naming conventions, no CSS modules, no specificity wars.
Styling from Outside
The host page can style the component's outer element using the tag name:
user-card {
margin-bottom: 16px;
max-width: 400px;
}
For deeper customization, the component can expose CSS custom properties:
// Inside the component
this.shadowRoot.innerHTML =
<style>
.card {
background: var(--card-bg, white);
border-color: var(--card-border, #e2e8f0);
border-radius: var(--card-radius, 8px);
}
.name {
color: var(--card-name-color, #1a202c);
}
</style>
...
;
/ The consuming page /
user-card {
--card-bg: #f8fafc;
--card-border: #3b82f6;
--card-name-color: #1e40af;
}
CSS custom properties pierce the shadow boundary. This is the official theming mechanism.
Slots — Composition Over Configuration
Instead of passing everything through attributes, let users put content inside the component:
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
const type = this.getAttribute("type") || "info";
const colors = {
info: { bg: "#eff6ff", border: "#3b82f6", icon: "ℹ️" },
warning: { bg: "#fffbeb", border: "#f59e0b", icon: "⚠️" },
error: { bg: "#fef2f2", border: "#ef4444", icon: "❌" },
success: { bg: "#f0fdf4", border: "#22c55e", icon: "✅" },
};
const c = colors[type] || colors.info;
this.shadowRoot.innerHTML =
<style>
:host { display: block; }
.alert {
display: flex;
gap: 12px;
padding: 16px;
border-left: 4px solid ${c.border};
background: ${c.bg};
border-radius: 4px;
font-family: system-ui, sans-serif;
}
.icon { font-size: 20px; }
.content { flex: 1; }
::slotted(strong) { display: block; margin-bottom: 4px; }
</style>
<div class="alert">
<span class="icon">${c.icon}</span>
<div class="content">
<slot></slot>
</div>
</div>
;
}
}
customElements.define("alert-box", AlertBox);
<alert-box type="warning">
<strong>Rate limit approaching</strong>
You've used 80% of your API quota this month.
</alert-box>
<alert-box type="error">
<strong>Payment failed</strong>
Your card was declined. Please update your payment method.
</alert-box>
Named slots let you distribute content to specific locations:
this.shadowRoot.innerHTML =
<style>/ ... /</style>
<div class="dialog">
<header><slot name="title">Default Title</slot></header>
<div class="body"><slot></slot></div>
<footer><slot name="actions"></slot></footer>
</div>
;
<modal-dialog>
<h2 slot="title">Confirm Deletion</h2>
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
<div slot="actions">
<button>Cancel</button>
<button class="danger">Delete</button>
</div>
</modal-dialog>
Lifecycle Callbacks
class DataFetcher extends HTMLElement {
static get observedAttributes() {
return ["url", "interval"];
}
connectedCallback() {
// Element added to the DOM
this.startFetching();
}
disconnectedCallback() {
// Element removed from the DOM
this.stopFetching();
}
attributeChangedCallback(name, oldValue, newValue) {
// An observed attribute changed
if (oldValue === newValue) return;
if (name === "url") this.startFetching();
if (name === "interval") this.restartTimer();
}
startFetching() {
const url = this.getAttribute("url");
if (!url) return;
fetch(url)
.then((r) => r.json())
.then((data) => this.render(data))
.catch((err) => this.renderError(err));
}
stopFetching() {
if (this._timer) clearInterval(this._timer);
}
restartTimer() {
this.stopFetching();
const interval = parseInt(this.getAttribute("interval") || "0");
if (interval > 0) {
this._timer = setInterval(() => this.startFetching(), interval);
}
}
}
| Callback | When It Fires |
|---|---|
constructor() | Element created (use for setup, shadow root) |
connectedCallback() | Element added to DOM (use for rendering, data fetching) |
disconnectedCallback() | Element removed from DOM (use for cleanup) |
attributeChangedCallback() | Observed attribute changed (use for reactive updates) |
adoptedCallback() | Element moved to new document (rare, usually iframes) |
Reactive Properties
Attributes are always strings. For complex data, use JavaScript properties:
class DataTable extends HTMLElement {
#data = [];
#columns = [];
set data(value) {
this.#data = value;
this.render();
}
get data() {
return this.#data;
}
set columns(value) {
this.#columns = value;
this.render();
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
render() {
if (!this.#columns.length || !this.#data.length) return;
this.shadowRoot.innerHTML =
<style>
table { width: 100%; border-collapse: collapse; font-family: system-ui; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
th { background: #f8fafc; font-weight: 600; font-size: 14px; color: #475569; }
tr:hover td { background: #f1f5f9; }
</style>
<table>
<thead>
<tr>${this.#columns.map((c) => <th>${c.label}</th>).join("")}</tr>
</thead>
<tbody>
${this.#data
.map(
(row) =>
<tr>${this.#columns.map((c) => <td>${row[c.key] ?? ""}</td>).join("")}</tr>
)
.join("")}
</tbody>
</table>
;
}
}
customElements.define("data-table", DataTable);
const table = document.querySelector("data-table");
table.columns = [
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "role", label: "Role" },
];
table.data = [
{ name: "Alice", email: "alice@example.com", role: "Admin" },
{ name: "Bob", email: "bob@example.com", role: "Editor" },
];
Using Web Components in Frameworks
React (with a wrapper):function UserCardWrapper({ name, role, avatar }) {
return <user-card name={name} role={role} avatar={avatar} />;
}
React 19 handles custom element properties correctly. Earlier versions need a ref:
function UserCardWrapper({ name, role, avatar }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.name = name;
}
}, [name]);
return <user-card ref={ref} role={role} avatar={avatar} />;
}
Vue (works out of the box):
<template>
<user-card :name="user.name" :role="user.role" :avatar="user.avatar" />
</template>
<script setup>
import { ref } from "vue";
const user = ref({ name: "Alice", role: "Admin", avatar: "/alice.jpg" });
</script>
Svelte:
<user-card name={user.name} role={user.role} avatar={user.avatar} />
When Web Components Make Sense
Use Web Components for:- Design system components shared across teams using different frameworks
- Widgets embedded in third-party sites (no dependency conflicts)
- Micro-frontend architecture (each team picks their own stack)
- Long-lived components that need to survive framework migrations
- Progressive enhancement of static HTML
- Your entire application (frameworks handle state, routing, and rendering better)
- Components that need deep framework integration (context, state management)
- Rapid prototyping (frameworks are faster to develop with)
Performance Characteristics
| Aspect | Web Components | React Components |
|---|---|---|
| Bundle size | 0 KB (native) | ~40 KB (React runtime) |
| First render | Immediate (no hydration) | Requires JS to hydrate |
| Style isolation | Built-in (Shadow DOM) | Requires CSS modules/styled-components |
| SSR | Declarative Shadow DOM (limited) | Mature SSR ecosystem |
| DevTools | Browser element inspector | React DevTools needed |
| Ecosystem | Small but growing | Massive |
If you're building a design system that needs to work everywhere, or embedding interactive widgets into documentation or third-party pages, Web Components are the right tool. CodeUp uses them for embeddable code snippets precisely because they work regardless of what framework (or no framework) the host page uses.