Web Components ohne Framework – Native Custom Elements in der Praxis

~16 Min. Lesezeit · Veröffentlicht am
Ich mag lightweight Lösungen. Mein Blog nutzt kein React, kein Vue, nicht mal jQuery – nur Vanilla JS und Alpine.js für minimale Interaktivität. Aber es gibt Situationen, in denen man wiederverwendbare Komponenten braucht: Ein konfigurierbarer Accordion, ein Custom Dropdown, ein Image-Slider.
Die klassische Lösung: Framework einbinden (100+ KB). Die bessere Lösung 2026: Web Components.
Was sind Web Components?
Web Components sind eine Sammlung von Web-Standards, die es ermöglichen, eigene HTML-Elemente zu erstellen – komplett mit eigenem Markup, Styling und Verhalten.
Die drei Säulen:
Eigene HTML-Tags definieren (z.B.
<user-card>)Gekapseltes CSS/HTML – kein Konflikt mit globalem Styling
Wiederverwendbare Markup-Schnipsel mit
<template>Ein einfaches Beispiel
So nutzt du eine Web Component:
<!-- HTML: Komponente nutzen -->
<user-card
name="Sven Muscheid"
role="Webentwickler"
avatar="avatar.jpg">
</user-card> Und so definierst du sie:
// JavaScript: Komponente definieren
class UserCard extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name');
const role = this.getAttribute('role');
const avatar = this.getAttribute('avatar');
this.innerHTML = `
<div class="user-card">
<img src="${avatar}" alt="${name}">
<h3>${name}</h3>
<p>${role}</p>
</div>
`;
}
}
// Komponente registrieren
customElements.define('user-card', UserCard); Das war's. Kein npm install, kein Build-Tool, keine Dependencies. Funktioniert in jedem modernen Browser.
Warum Web Components? (Oder: Warum nicht React?)
Die ehrliche Frage: Wann brauchst du Web Components statt React/Vue/Svelte?
| Kriterium | Web Components | React/Vue/Svelte |
|---|---|---|
| Dependencies | ✓ Keine (nur Browser) | ✗ Framework nötig (30-100+ KB) |
| Build-Tools | ✓ Optional | ✗ Meist zwingend (Webpack, Vite) |
| Framework-Lock-in | ✓ Framework-agnostisch | ✗ An Framework gebunden |
| Ladezeit | ✓ Minimal (nur eigener Code) | ~ Framework + App-Code |
| Browser-Support | ✓ Alle modernen Browser | ✓ Auch modern (mit Polyfills) |
| DevTools/Ecosystem | ~ Basic (verbessert sich) | ✓ Ausgereift (DevTools, Libraries) |
| State Management | ~ Manuell (oder Library) | ✓ Eingebaut (Hooks, Stores) |
| Reactivity | ~ Manuell | ✓ Automatisch |
| Lernkurve | ✓ Flach (Vanilla JS) | ~ Steiler (Framework-Konzepte) |
| Use Case | Einzelne Komponenten, Widgets, Progressive Enhancement | Komplexe Apps, SPAs, viel Interaktivität |
Web Components wenn:
- Du einzelne interaktive Komponenten brauchst (nicht eine ganze App)
- Performance/Bundle-Size wichtig ist
- Du framework-agnostisch bleiben willst
- Progressive Enhancement wichtig ist
- Du kein Build-Setup willst
Framework wenn:
- Du eine komplexe SPA baust
- Viel State Management nötig ist
- Dein Team bereits Framework-Expertise hat
- Du auf ein ausgereiftes Ecosystem angewiesen bist
Die drei Säulen im Detail
1. Custom Elements – Eigene HTML-Tags
Custom Elements erlauben dir, eigene HTML-Tags zu definieren. Es gibt zwei Arten:
Autonomous Custom Elements (von scratch)
// Definition
class SimpleGreeting extends HTMLElement {
connectedCallback() {
this.innerHTML = `<p>Hallo, Welt!</p>`;
}
}
customElements.define('simple-greeting', SimpleGreeting);
// Nutzung
<simple-greeting></simple-greeting> Customized Built-in Elements (erweitern bestehende Elemente)
// Definition (erweitert <button>)
class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.addEventListener('click', () => {
this.classList.add('clicked');
});
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Nutzung
<button is="fancy-button">Click me</button> user-card, nicht usercard). Das verhindert Kollisionen mit zukünftigen HTML-Standard-Tags.2. Shadow DOM – Style-Kapselung
Shadow DOM erstellt einen "parallelen DOM-Baum" innerhalb deiner Komponente – komplett isoliert vom Rest der Seite.
Problem ohne Shadow DOM:
// Globales CSS
.card { background: red; }
// Deine Komponente
class UserCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `<div class="card">...</div>`;
}
}
// Resultat: Komponente hat roten Hintergrund (ungewollt!) Lösung mit Shadow DOM:
class UserCard extends HTMLElement {
connectedCallback() {
// Shadow Root erstellen
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Nur für diese Komponente */
.card {
background: blue;
padding: 1rem;
border-radius: 8px;
}
</style>
<div class="card">
<h3>${this.getAttribute('name')}</h3>
</div>
`;
}
}
customElements.define('user-card', UserCard); Vorteile:
- CSS innerhalb der Komponente betrifft nur die Komponente
- Globales CSS betrifft die Komponente nicht (Ausnahme: vererbbare Properties wie
color,font-family) - Kein CSS-Klassenname-Konflikt möglich
mode: 'open'– JavaScript kann von außen auf Shadow Root zugreifen (element.shadowRoot)mode: 'closed'– Kein Zugriff von außen (selten genutzt)
3. HTML Templates – Wiederverwendbare Markup-Schnipsel
Mit <template> definierst du HTML, das initial nicht gerendert wird – perfekt für Komponenten.
<!-- HTML: Template definieren -->
<template id="user-card-template">
<style>
.card {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
}
</style>
<div class="card">
<img class="avatar" src="" alt="">
<h3 class="name"></h3>
<p class="role"></p>
</div>
</template>
<script>
class UserCard extends HTMLElement {
connectedCallback() {
const template = document.getElementById('user-card-template');
const content = template.content.cloneNode(true);
// Daten einfügen
content.querySelector('.avatar').src = this.getAttribute('avatar');
content.querySelector('.avatar').alt = this.getAttribute('name');
content.querySelector('.name').textContent = this.getAttribute('name');
content.querySelector('.role').textContent = this.getAttribute('role');
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(content);
}
}
customElements.define('user-card', UserCard);
</script> Praxisbeispiel: Akkordeon-Komponente
Bauen wir eine vollständige, wiederverwendbare Akkordeon-Komponente – mit Animation, Accessibility und Shadow DOM.
<template id="accordion-template">
<style>
:host {
display: block;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(255,255,255,0.05);
cursor: pointer;
user-select: none;
}
.header:hover {
background: rgba(255,255,255,0.08);
}
.icon {
transition: transform 0.3s;
}
.icon.open {
transform: rotate(180deg);
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.content.open {
max-height: 500px;
}
.content-inner {
padding: 1rem;
}
</style>
<div class="header" role="button" tabindex="0" aria-expanded="false">
<span class="title"></span>
<span class="icon">▼</span>
</div>
<div class="content">
<div class="content-inner">
<slot></slot>
</div>
</div>
</template>
<script>
class AccordionItem extends HTMLElement {
constructor() {
super();
const template = document.getElementById('accordion-template');
const content = template.content.cloneNode(true);
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(content);
this.header = shadow.querySelector('.header');
this.contentEl = shadow.querySelector('.content');
this.icon = shadow.querySelector('.icon');
this.titleEl = shadow.querySelector('.title');
}
connectedCallback() {
// Titel setzen
this.titleEl.textContent = this.getAttribute('title') || 'Accordion Item';
// Event Listener
this.header.addEventListener('click', () => this.toggle());
this.header.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
const isOpen = this.contentEl.classList.contains('open');
if (isOpen) {
this.contentEl.classList.remove('open');
this.icon.classList.remove('open');
this.header.setAttribute('aria-expanded', 'false');
} else {
this.contentEl.classList.add('open');
this.icon.classList.add('open');
this.header.setAttribute('aria-expanded', 'true');
}
// Custom Event dispatchen
this.dispatchEvent(new CustomEvent('accordion-toggle', {
detail: { open: !isOpen }
}));
}
}
customElements.define('accordion-item', AccordionItem);
</script> Nutzung:
<accordion-item title="Was sind Web Components?">
<p>Web Components sind eine Sammlung von Browser-Standards...</p>
</accordion-item>
<accordion-item title="Wie nutze ich Shadow DOM?">
<p>Shadow DOM wird mit attachShadow() erstellt...</p>
</accordion-item> Was passiert hier?
<template>: Markup-Vorlage, wird initial nicht gerendert:host: Styled das Custom Element selbst (nicht den Inhalt)<slot>: Platzhalter für Content von außen (zwischen<accordion-item>...</accordion-item>)- Shadow DOM: Styles sind isoliert, keine Kollisionen mit globalem CSS
- Accessibility:
role="button",tabindex,aria-expanded, Keyboard-Support - Custom Events: Component dispatched ein Event wenn geöffnet/geschlossen wird
Lifecycle Callbacks
Web Components haben Lifecycle-Methoden ähnlich wie React/Vue:
| Callback | Wann wird aufgerufen? | Use Case |
|---|---|---|
constructor() | Element wird erstellt | Shadow DOM erstellen, initialen State setzen. Kein DOM-Access! |
connectedCallback() | Element wird ins DOM eingefügt | Event Listener, DOM-Manipulation, Daten laden |
disconnectedCallback() | Element wird aus DOM entfernt | Cleanup, Event Listener entfernen, Timers stoppen |
attributeChangedCallback() | Attribute wird geändert | Auf Attribut-Änderungen reagieren (nur observierte Attribute) |
adoptedCallback() | Element wird in neues Document verschoben | Selten genutzt (iframes, document.adoptNode) |
Attribute beobachten
class UserCard extends HTMLElement {
// Welche Attribute beobachtet werden sollen
static get observedAttributes() {
return ['name', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`);
if (name === 'name') {
this.shadowRoot.querySelector('.name').textContent = newValue;
}
if (name === 'role') {
this.shadowRoot.querySelector('.role').textContent = newValue;
}
}
} Slots – Content von außen einfügen
Slots sind Platzhalter für Content, den der Nutzer der Komponente bereitstellt.
Default Slot (unnamed)
<!-- Komponente -->
<template>
<div class="wrapper">
<slot></slot>
</div>
</template>
<!-- Nutzung -->
<my-component>
<p>Dieser Content erscheint im Slot</p>
</my-component> Named Slots
<!-- Komponente -->
<template>
<div class="card">
<div class="header">
<slot name="header">Default Header</slot>
</div>
<div class="body">
<slot>Default Content</slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<!-- Nutzung -->
<card-component>
<h2 slot="header">Mein Titel</h2>
<p>Dieser Text landet im Default Slot</p>
<button slot="footer">Action</button>
</card-component> Web Components + Tailwind CSS
Kann man Tailwind mit Web Components nutzen? Ja, aber mit Einschränkungen.
Problem: Shadow DOM isoliert CSS
Tailwind-Klassen die außerhalb der Komponente definiert sind, funktionieren nicht innerhalb des Shadow DOM.
<!-- Funktioniert NICHT -->
<user-card class="p-4 bg-blue-500"></user-card>
// Shadow DOM blockiert globale Styles Lösungen:
Komponente nutzt normales DOM, Tailwind funktioniert normal. Nachteil: Kein Style-Isolation.
Tailwind-CSS-Datei per <link> oder <style> in Shadow Root einfügen.
Shadow DOM erbt CSS Custom Properties. Tailwind über Custom Properties stylen.
Moderne API um Stylesheets zwischen Shadow Roots zu teilen (Browser-Support 2026: gut).
Praxis-Beispiel: Tailwind mit Constructable Stylesheets
// Einmal: Tailwind als Stylesheet erstellen
const tailwindSheet = new CSSStyleSheet();
tailwindSheet.replaceSync(`
/* Tailwind CSS hier (oder aus File laden) */
@import url('/path/to/tailwind.css');
`);
// In jeder Komponente nutzen
class MyComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Tailwind-Sheet hinzufügen
shadow.adoptedStyleSheets = [tailwindSheet];
shadow.innerHTML = `
<div class="p-4 text-white bg-blue-500 rounded">
Tailwind funktioniert!
</div>
`;
}
} Browser-Support 2026
- Chrome/Edge: Seit 2018 (v67+)
- Firefox: Seit 2018 (v63+)
- Safari: Seit 2020 (v14+)
- Opera: Seit 2018 (v54+)
Support-Rate 2026: 95%+ aller Browser weltweit
Polyfill nötig? Nur für sehr alte Browser (IE11, Safari <14). In 2026 praktisch nicht mehr relevant.
Wann Web Components NICHT nutzen
- Komplexe SPAs: Kein eingebautes State Management, Routing, etc. → Framework besser
- Server-Side Rendering: Web Components sind client-only (aber: Lit, Stencil bieten SSR)
- Wenn das Team bereits React-Expertise hat: Don't fix what ain't broken
- Wenn du ein ausgebautes Ecosystem brauchst: React/Vue haben mehr fertige Komponenten
Libraries für Web Components
Vanilla Web Components sind mächtig, aber manchmal willst du mehr Komfort:
| Library | Was bringt's? | Bundle Size |
|---|---|---|
| Lit (by Google) | Reactive Properties, Templating, Lifecycle-Komfort. Industry-Standard für WC. | ~5 KB |
| Stencil | TypeScript, JSX, Compiler (generiert optimierte WC). Ionic nutzt das. | ~0 KB (kompiliert weg) |
| Haunted | React Hooks API für Web Components (useState, useEffect, etc.) | ~2 KB |
| FAST (by Microsoft) | Design System + Web Components, sehr performant | Variabel |
Für einfache Komponenten: Vanilla Web Components (0 Dependencies)
Für mehrere komplexere Komponenten: Lit (minimaler Overhead, gute DX)
Für große Component Libraries: Stencil (best tooling, TypeScript, kompiliert zu optimierten WC)
Mein Praxis-Setup: Web Components + Alpine.js
Auf meinem Blog nutze ich eine Kombi:
- Statische Seiten: Vanilla HTML/CSS
- Minimale Interaktivität: Alpine.js (Mobile-Menü, Accordions, etc.)
- Wiederverwendbare Widgets: Web Components (z.B.
<code-snippet>mit Syntax-Highlighting + Copy-Button)
Resultat: 100/100 Lighthouse Score, ~15 KB JavaScript gesamt, kein Build-Tool nötig.
<!-- Beispiel: Code-Snippet Komponente -->
<code-snippet language="javascript">
const greeting = 'Hello World';
console.log(greeting);
</code-snippet>
<script>
class CodeSnippet extends HTMLElement {
connectedCallback() {
const language = this.getAttribute('language') || 'text';
const code = this.textContent.trim();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.snippet {
position: relative;
background: #1e1e1e;
border-radius: 8px;
padding: 1rem;
}
.copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(255,255,255,0.1);
border: none;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
}
.copy-btn:hover {
background: rgba(255,255,255,0.2);
}
pre {
margin: 0;
overflow-x: auto;
}
code {
font-family: 'Courier New', monospace;
color: #7CD5EE;
}
</style>
<div class="snippet">
<button class="copy-btn" title="Copy to clipboard">📋</button>
<pre><code class="language-${language}">${code}</code></pre>
</div>
`;
// Copy to Clipboard
shadow.querySelector('.copy-btn').addEventListener('click', async () => {
await navigator.clipboard.writeText(code);
// Feedback zeigen
const btn = shadow.querySelector('.copy-btn');
btn.textContent = '✓';
setTimeout(() => btn.textContent = '📋', 2000);
});
}
}
customElements.define('code-snippet', CodeSnippet);
</script> Fazit: Web Components in 2026
Web Components sind erwachsen geworden. Browser-Support ist exzellent, die APIs sind stabil, und für viele Use Cases sind sie die bessere Wahl als ein komplettes Framework.
- Einzelne wiederverwendbare UI-Komponenten (Buttons, Cards, Accordions)
- Widgets die in verschiedenen Projekten/Frameworks funktionieren sollen
- Progressive Enhancement (Component funktioniert, auch wenn JS fehlschlägt)
- Performance-kritische Seiten (kein Framework-Overhead)
- Wenn ich kein Build-Setup will
- Komplexe SPAs mit viel State
- Große Teams mit Framework-Expertise
- Wenn ich auf fertiges Ecosystem angewiesen bin (Component Libraries, Router, etc.)
- Rapid Prototyping (React DevTools, Hot Reload, etc.)
Die Zukunft ist nicht "Framework ODER Web Components" – sondern beides, für unterschiedliche Zwecke. Web Components sind das Missing Piece für lightweight, framework-agnostische UI-Komponenten.
„Web Components sind wie Unix-Tools: Mach eine Sache, mach sie gut, und spiel gut mit anderen."
Mehr zum Thema Frontend-Entwicklung: Alpine.js für dynamische Websites, Tailwind CSS Framework, TypeScript Migration.