Web Components: eine praktische Einführung
Wie man frameworkunabhängige Web Components mit Custom Elements, Shadow DOM und HTML Templates baut — wiederverwendbar in jeder Umgebung.

Mit Web Components lassen sich wiederverwendbare UI-Elemente bauen, die in jedem Framework funktionieren — oder auch ganz ohne Framework. Sie basieren auf drei Browser-APIs: Custom Elements (zum Definieren neuer HTML-Tags), Shadow DOM (gekapselte Styles und gekapseltes Markup) und HTML Templates (wiederverwendbare Markup-Fragmente). Anders als React- oder Vue-Komponenten sind Web Components ein Browserstandard. Eine heute gebaute Komponente funktioniert auch in zehn Jahren noch, ganz ohne Build-Schritt.
Der praktische Anwendungsfall sind Designsysteme. Wenn in deiner Organisation Teams mit React, Angular und reinem JavaScript arbeiten, kannst du mit Web Components gemeinsame UI-Elemente einmal bauen und überall einsetzen.
Custom Elements
Mit Custom Elements definierst du neue HTML-Tags mit eigenem Verhalten. Der Browser behandelt sie wie native Elemente — sie funktionieren in HTML, lassen sich mit document.querySelector abfragen und lösen Events aus.
// Define a custom element
class UserCard extends HTMLElement {
// Observed attributes trigger attributeChangedCallback
static observedAttributes = ['name', 'email', 'avatar'];
constructor() {
super();
// Attach shadow DOM for style encapsulation
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Called when element is added to the DOM
this.render();
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
) {
// Called when an observed attribute changes
if (oldValue !== newValue) {
this.render();
}
}
private render() {
const name = this.getAttribute('name') ?? 'Unknown';
const email = this.getAttribute('email') ?? '';
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;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.name {
font-weight: 600;
font-size: 1rem;
}
.email {
color: #64748b;
font-size: 0.875rem;
}
</style>
<div class="card">
${avatar ? `<img class="avatar" src="${this.escapeHtml(avatar)}" alt="${this.escapeHtml(name)}" />` : ''}
<div>
<div class="name">${this.escapeHtml(name)}</div>
<div class="email">${this.escapeHtml(email)}</div>
</div>
</div>
`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Register the custom element
customElements.define('user-card', UserCard);<!-- Usage — works in any HTML page, any framework -->
<user-card
name="Sarah Chen"
email="sarah@example.com"
avatar="/avatars/sarah.jpg"
></user-card>
<!-- Dynamically create in JavaScript -->
<script>
const card = document.createElement('user-card');
card.setAttribute('name', 'Alex Rivera');
card.setAttribute('email', 'alex@example.com');
document.body.appendChild(card);
</script>Shadow-DOM-Kapselung
Shadow DOM erzeugt einen isolierten DOM-Baum innerhalb deiner Komponente. Styles, die innerhalb des Shadow Roots definiert sind, dringen nicht nach außen, und externe Styles dringen nicht nach innen. Das ist der entscheidende Vorteil gegenüber Framework-Komponenten — echte Style-Kapselung auf Browserebene.
// ❌ Without Shadow DOM — styles leak
class BadButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button { background: red; color: white; }
/* This style affects ALL buttons on the page! */
</style>
<button>Click me</button>
`;
}
}
// ✅ With Shadow DOM — styles are encapsulated
class GoodButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
button {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover {
background: #2563eb;
}
/* Only affects the button inside this shadow root */
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('ui-button', GoodButton);<!-- Slot allows passing content from light DOM -->
<ui-button>Save Changes</ui-button>
<ui-button>Cancel</ui-button>Slots und Komposition
Mit Slots kann die Komponente von außen Inhalte entgegennehmen, ähnlich wie bei der children-Prop von React oder den Slots von Vue.
class AlertBox extends HTMLElement {
static observedAttributes = ['type'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
private render() {
const type = this.getAttribute('type') ?? 'info';
const colors: Record<string, { bg: string; border: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6' },
success: { bg: '#f0fdf4', border: '#22c55e' },
warning: { bg: '#fffbeb', border: '#f59e0b' },
error: { bg: '#fef2f2', border: '#ef4444' },
};
const color = colors[type] ?? colors.info;
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
}
.alert {
padding: 12px 16px;
border-left: 4px solid ${color.border};
background: ${color.bg};
border-radius: 4px;
}
.title {
font-weight: 600;
margin-bottom: 4px;
}
::slotted(*) {
margin: 0;
}
</style>
<div class="alert">
<div class="title"><slot name="title"></slot></div>
<div class="body"><slot></slot></div>
</div>
`;
}
}
customElements.define('alert-box', AlertBox);<!-- Named and default slots -->
<alert-box type="warning">
<span slot="title">Rate Limit Warning</span>
<p>You have 5 API calls remaining in the current window.</p>
</alert-box>
<alert-box type="success">
<span slot="title">Deployment Complete</span>
<p>Version 2.3.1 is now live in production.</p>
</alert-box>Benutzerdefinierte Events
Web Components kommunizieren über benutzerdefinierte Events mit der Außenwelt — nach demselben Muster wie native DOM-Events.
class SearchInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<style>
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
</style>
<input type="search" placeholder="${this.getAttribute('placeholder') ?? 'Search...'}" />
`;
const input = this.shadowRoot!.querySelector('input')!;
let debounceTimer: ReturnType<typeof setTimeout>;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// Dispatch custom event with search value
this.dispatchEvent(
new CustomEvent('search', {
detail: { query: input.value },
bubbles: true, // Propagates up the DOM
composed: true, // Crosses shadow DOM boundary
})
);
}, 300);
});
}
}
customElements.define('search-input', SearchInput);<search-input placeholder="Search users..."></search-input>
<script>
document
.querySelector('search-input')
.addEventListener('search', (event) => {
console.log('Search query:', event.detail.query);
// Fetch results, filter list, etc.
});
</script>Web Components in Frameworks verwenden
Web Components funktionieren in React, Vue, Angular und Svelte. Die Integration in Frameworks läuft meist reibungslos, mit ein paar Besonderheiten beim Übergeben von Properties gegenüber dem Setzen von Attributen.
// React — use web components directly in JSX
function UserList({ users }: { users: User[] }) {
return (
<div>
{users.map((user) => (
<user-card
key={user.id}
name={user.name}
email={user.email}
avatar={user.avatar}
/>
))}
</div>
);
}
// React — listening to custom events requires a ref
function SearchPage() {
const searchRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = searchRef.current;
if (!el) return;
const handler = (e: Event) => {
const query = (e as CustomEvent).detail.query;
console.log('Search:', query);
};
el.addEventListener('search', handler);
return () => el.removeEventListener('search', handler);
}, []);
return <search-input ref={searchRef} placeholder="Search..." />;
}// ❌ Passing complex data as attributes (serialized to strings)
<user-card settings='{"theme":"dark","lang":"en"}'></user-card>
// Attributes are always strings — parsing JSON on every render is wasteful
// ✅ Passing complex data as properties
const card = document.querySelector('user-card') as UserCard;
card.settings = { theme: 'dark', lang: 'en' };
// Properties can be any JavaScript type — no serialization neededDie wichtigsten Erkenntnisse
- Web Components sind ein Browserstandard — sie funktionieren überall ohne Build-Schritt und brechen nicht, wenn sich Frameworks ändern
- Shadow DOM bietet echte Style-Kapselung — Styles innerhalb einer Komponente können nicht nach außen dringen, externe Styles nicht nach innen
- Nutze Slots für die Komposition — benannte und Standard-Slots lassen die Komponente von außen Inhalte entgegennehmen, ähnlich wie props.children in React
- Löse für die Kommunikation benutzerdefinierte Events aus — setze
bubbles: trueundcomposed: true, damit Events die Shadow-DOM-Grenzen überschreiten - Bevorzuge Properties gegenüber Attributen für komplexe Daten — Attribute sind immer Strings; Properties akzeptieren jeden JavaScript-Typ
- Am besten geeignet für Designsysteme — wenn mehrere Frameworks dieselben UI-Elemente brauchen, sind Web Components die gemeinsame Grundlage


