March 26, 20268 min read

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.

web components shadow dom custom elements html framework
Ad 336x280

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:

  1. Custom Elements — define new HTML tags with JavaScript behavior
  2. Shadow DOM — encapsulated DOM and styles that don't leak
  3. HTML Templates — reusable markup fragments
You can use each independently, but they work best together.

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);
}
}
}

CallbackWhen 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
Don't use Web Components for:
  • 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)
The sweet spot is shared infrastructure: buttons, inputs, cards, modals, tooltips, data tables. Things that every team needs and shouldn't be rewritten when you migrate from React to whatever comes next.

Performance Characteristics

AspectWeb ComponentsReact Components
Bundle size0 KB (native)~40 KB (React runtime)
First renderImmediate (no hydration)Requires JS to hydrate
Style isolationBuilt-in (Shadow DOM)Requires CSS modules/styled-components
SSRDeclarative Shadow DOM (limited)Mature SSR ecosystem
DevToolsBrowser element inspectorReact DevTools needed
EcosystemSmall but growingMassive
Web Components are lighter and faster to render. Frameworks offer better DX and larger ecosystems. Pick based on your use case.

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.

Ad 728x90