Use Cases (Aktualisiert: 2.6.2026)

Web Components mit Claude Code entwickeln

Web Components mit Claude Code entwickeln: Custom Elements, Shadow DOM, React/Vue-Integration, Review-Prompts und Tests.

Web Components mit Claude Code entwickeln

Warum Web Components zu Claude Code passen

Web Components sind Browser-Standards für wiederverwendbare HTML-Elemente. Custom Elements registrieren eigene Tags, Shadow DOM kapselt internes Markup und CSS, und Templates oder Slots helfen bei wiederholbarer Struktur. Der Zweck ist nicht, React oder Vue zu ersetzen. Der Zweck ist ein kleiner, stabiler UI-Vertrag, der in mehreren Umgebungen funktioniert.

Claude Code ist dafür nützlich, weil Web Components vor allem an unklaren Grenzen scheitern. Welche Attribute sind öffentlich? Welche JavaScript-Property darf gesetzt werden? Wie heißt das Event? Darf es aus dem Shadow DOM heraus? Welche CSS-Hooks sind erlaubt? Welche Accessibility-Regeln und Tests schützen das Verhalten? Wenn diese Fragen im Prompt stehen, kann Claude Code implementieren, prüfen und Tests ergänzen, statt nur Demo-Code zu erzeugen.

Masa hatte dieses Problem bei einem React-basierten Mengenfeld, das später in einem CMS und in einer Vue-Oberfläche gebraucht wurde. Für ein kleines Widget React im CMS zu laden war zu teuer, Kopieren führte zu abweichendem Verhalten. Als Web Component konnte dieselbe Balise in statischem HTML, CMS, React und Vue laufen. Die schwierigen Stellen waren nicht die Registrierung des Tags, sondern Shadow-DOM-Styling, Event-Design, Accessibility und Versionierung.

Dieser Artikel baut quantity-stepper: minimale Custom-Element-Implementierung, Attribute-Observation, CustomEvent, Styling-Hooks, Nutzung aus React und Vue, Claude-Code-Review-Prompt und Vitest-Tests. Für die Einordnung in ein Team-Design-System ist der Claude Code Design System Guide die passende Ergänzung.

Offizielle Grundlagen stehen bei MDN Web Components, MDN Using custom elements und MDN CustomEvent.

Geeignete Einsatzfälle

Web Components sind stark, wenn ein kleines, abgeschlossenes UI in mehreren Stacks funktionieren soll. Sie sind weniger passend für ganze Seiten mit Routing, globalem State und komplexem SSR. Beschreibe Claude Code daher zuerst die Zielumgebungen und die Grenzen der Verantwortung.

EinsatzfallWarum es passtVorgaben für Claude Code
Design-System-VerteilungReact, Vue und statische Seiten nutzen dasselbe PaketTokens, Größen, Zustände, Accessibility
CMS-EinbettungRedakteure erhalten Interaktion ohne App-Runtimekein globales CSS, sichere Script-Reihenfolge
Gemeinsame UI bei MigrationAlte und neue Frontends nutzen dasselbe ElementAttribute, Events, Breaking-Change-Regeln
FormularbausteineMenge, Rating, Autocomplete und Hilfen sind wiederverwendbarLabel, Fehler, Tastatur, Formularbezug
Interne WidgetsAdmin-Portale und BI-Seiten teilen kleine Toolskeine Secrets in Attributen, Logs, Version

Gute Kandidaten sind Copy-Buttons, Mengenfelder, Ratings, kleine Suchfelder und Hilfe-Launcher. Ein kompletter Checkout oder ein komplexer Editor sollte eher im Hauptframework bleiben. Wenn ein Element zu viele Verantwortlichkeiten bekommt, wird auch der Claude-Code-Diff unklar.

flowchart LR
  A["Design tokens"] --> B["Web Component package"]
  B --> C["React app"]
  B --> D["Vue app"]
  B --> E["CMS page"]
  B --> F["Static HTML"]
  B --> G["Internal widget"]

Minimales Custom Element

Speichere den folgenden Code als quantity-stepper.ts. Das Element liest HTML-Attribute, normalisiert Zahlen, spiegelt den Wert zurück und sendet bei Benutzerinteraktion quantity-change.

const toNumber = (value: string | null, fallback: number) => {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
};

class QuantityStepper extends HTMLElement {
  static observedAttributes = ["value", "step", "label"];

  #root = this.attachShadow({ mode: "open" });
  #value = 0;
  #step = 1;

  connectedCallback() {
    this.#syncFromAttributes();
    this.#render();
  }

  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
    if (oldValue === newValue || !this.isConnected) return;
    this.#syncFromAttributes();
    this.#render();
  }

  get value() {
    return this.#value;
  }

  set value(nextValue: number) {
    const normalized = Number.isFinite(nextValue) ? nextValue : 0;
    if (normalized === this.#value) return;

    this.#value = normalized;
    this.setAttribute("value", String(normalized));
    this.#emitChange();
    this.#render();
  }

  #syncFromAttributes() {
    this.#value = toNumber(this.getAttribute("value"), 0);
    this.#step = toNumber(this.getAttribute("step"), 1);
  }

  #update(direction: -1 | 1) {
    this.value = this.#value + this.#step * direction;
  }

  #emitChange() {
    this.dispatchEvent(
      new CustomEvent("quantity-change", {
        detail: { value: this.#value },
        bubbles: true,
        composed: true,
      }),
    );
  }

  #render() {
    const label = this.getAttribute("label") || "Quantity";

    this.#root.innerHTML = `
      <style>
        :host {
          --quantity-accent: #2563eb;
          --quantity-border: #cbd5e1;
          --quantity-bg: #ffffff;
          --quantity-text: #0f172a;
          display: inline-flex;
          font-family: system-ui, sans-serif;
        }
        .control {
          display: inline-grid;
          grid-template-columns: 2.5rem minmax(3rem, auto) 2.5rem;
          align-items: center;
          border: 1px solid var(--quantity-border);
          border-radius: 8px;
          background: var(--quantity-bg);
          color: var(--quantity-text);
          overflow: hidden;
        }
        button {
          min-width: 2.5rem;
          min-height: 2.5rem;
          border: 0;
          background: transparent;
          color: var(--quantity-accent);
          font: inherit;
          cursor: pointer;
        }
        button:focus-visible {
          outline: 2px solid var(--quantity-accent);
          outline-offset: -2px;
        }
        output {
          min-width: 3rem;
          text-align: center;
          font-weight: 700;
        }
        .sr-only {
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border: 0;
        }
      </style>

      <div class="control" role="group" aria-label="${label}">
        <button part="button decrement" data-action="decrement" type="button">
          <span aria-hidden="true">-</span>
          <span class="sr-only">Decrease ${label}</span>
        </button>
        <output part="value" aria-live="polite">${this.#value}</output>
        <button part="button increment" data-action="increment" type="button">
          <span aria-hidden="true">+</span>
          <span class="sr-only">Increase ${label}</span>
        </button>
      </div>
    `;

    this.#root
      .querySelector('[data-action="decrement"]')
      ?.addEventListener("click", () => this.#update(-1));

    this.#root
      .querySelector('[data-action="increment"]')
      ?.addEventListener("click", () => this.#update(1));
  }
}

if (!customElements.get("quantity-stepper")) {
  customElements.define("quantity-stepper", QuantityStepper);
}
<script type="module" src="/src/quantity-stepper.ts"></script>

<quantity-stepper value="2" step="1" label="Seats"></quantity-stepper>

Attribute, Properties und Events

HTML-Attribute sind Strings. JavaScript-Properties können Zahlen, Booleans oder Objekte sein. value="2" und element.value = 2 sind deshalb unterschiedliche Eingänge. Das Element muss festlegen, wie beide synchronisiert werden.

Dieses Beispiel beobachtet value, step und label. Ein Attributwechsel synchronisiert internen State und rendert neu. Ein Klick läuft über den value-Setter, aktualisiert das Attribut, sendet das Event und rendert erneut.

const stepper = document.querySelector("quantity-stepper");

stepper?.addEventListener("quantity-change", (event) => {
  const { value } = (event as CustomEvent<{ value: number }>).detail;
  console.log("Selected quantity:", value);
});

Das Event ist öffentlicher Vertrag. quantity-change ist klarer als change, { value: number } ist ein kleines stabiles Payload, und composed: true erlaubt das Empfangen außerhalb des Shadow DOM.

Shadow-DOM-Styling

Shadow DOM schützt vor globalem CSS. Das ist in CMS-Seiten und alten Portalen nützlich, aber ohne Theme-Hooks kann das Design-System Farben und Abstände nicht steuern.

quantity-stepper {
  --quantity-accent: #16a34a;
  --quantity-border: #94a3b8;
  --quantity-bg: #f8fafc;
}

quantity-stepper::part(button) {
  font-weight: 700;
}

quantity-stepper::part(value) {
  min-width: 4rem;
}

Gib Claude Code beide Regeln: keine Abhängigkeit von globalem CSS, aber dokumentierte CSS Custom Properties und part-Namen. Prüfe außerdem sichtbaren Fokus, native Buttons, Labels und aria-live. Der Claude Code Accessibility Guide passt als Review-Checkliste.

Nutzung aus React und Vue

React rendert das Custom Element, aber CustomEvent-Handling ist über ein ref am zuverlässigsten.

import { useEffect, useRef, useState } from "react";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import "./quantity-stepper";

type QuantityStepperElement = HTMLElement & { value: number };

declare global {
  namespace JSX {
    interface IntrinsicElements {
      "quantity-stepper": DetailedHTMLProps<
        HTMLAttributes<QuantityStepperElement>,
        QuantityStepperElement
      > & {
        value?: string;
        step?: string;
        label?: string;
      };
    }
  }
}

export function QuantityField() {
  const [quantity, setQuantity] = useState(2);
  const ref = useRef<QuantityStepperElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleQuantityChange = (event: Event) => {
      setQuantity((event as CustomEvent<{ value: number }>).detail.value);
    };

    element.addEventListener("quantity-change", handleQuantityChange);
    return () => {
      element.removeEventListener("quantity-change", handleQuantityChange);
    };
  }, []);

  return (
    <div>
      <quantity-stepper ref={ref} value="2" step="1" label="Seats" />
      <p>Selected: {quantity}</p>
    </div>
  );
}

Vue nutzt ein Template-Ref und entfernt den Listener beim Unmount.

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import "./quantity-stepper";

const quantity = ref(2);
const stepper = ref<HTMLElement | null>(null);

const handleQuantityChange = (event: Event) => {
  quantity.value = (event as CustomEvent<{ value: number }>).detail.value;
};

onMounted(() => {
  stepper.value?.addEventListener("quantity-change", handleQuantityChange);
});

onBeforeUnmount(() => {
  stepper.value?.removeEventListener("quantity-change", handleQuantityChange);
});
</script>

<template>
  <quantity-stepper ref="stepper" value="2" step="1" label="Seats" />
  <p>Selected: {{ quantity }}</p>
</template>
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag === "quantity-stepper",
        },
      },
    }),
  ],
});

Claude-Code-Review-Prompt

You are editing only src/components/quantity-stepper.ts and its tests.

Goal:
Build a framework-agnostic Web Component named quantity-stepper.

Public API:
- attributes: value, step, label
- property: value as number
- event: quantity-change with detail { value: number }
- event must bubble and cross Shadow DOM with composed: true

Implementation rules:
- Use Custom Elements and Shadow DOM.
- Do not depend on React or Vue.
- Expose theme hooks with CSS Custom Properties.
- Expose ::part(button) and ::part(value).
- Keep keyboard and screen reader behavior usable.
- Do not remove visible focus styles.

Review before finishing:
- Attribute/property synchronization
- Event naming and detail shape
- Shadow DOM styling boundaries
- Accessibility labels and aria-live
- Test coverage for click, attribute update, and event emission
Review this Web Component as production code.
Prioritize bugs over style preferences.
Report issues in this order:
1. Broken public API or breaking change risk
2. Accessibility defects
3. Shadow DOM styling leaks
4. React/Vue integration problems
5. Missing tests

For every finding, include the file, line, why it fails, and a minimal fix.

So prüft Claude Code nicht nur die Optik, sondern den öffentlichen Vertrag. Eine kurze Version gehört in CLAUDE.md, wenn mehrere Teammitglieder Komponenten ergänzen.

Tests

import { beforeEach, describe, expect, it } from "vitest";
import "./quantity-stepper";

describe("quantity-stepper", () => {
  beforeEach(() => {
    document.body.innerHTML = "";
  });

  it("increments by step and emits a composed event", () => {
    const element = document.createElement("quantity-stepper") as HTMLElement & {
      value: number;
    };

    element.setAttribute("value", "3");
    element.setAttribute("step", "2");
    element.setAttribute("label", "Seats");
    document.body.append(element);

    const events: Array<CustomEvent<{ value: number }>> = [];
    element.addEventListener("quantity-change", (event) => {
      events.push(event as CustomEvent<{ value: number }>);
    });

    element.shadowRoot
      ?.querySelector<HTMLButtonElement>('[data-action="increment"]')
      ?.click();

    expect(element.getAttribute("value")).toBe("5");
    expect(element.value).toBe(5);
    expect(events).toHaveLength(1);
    expect(events[0].detail.value).toBe(5);
    expect(events[0].bubbles).toBe(true);
    expect(events[0].composed).toBe(true);
  });

  it("updates rendering when the value attribute changes", () => {
    const element = document.createElement("quantity-stepper");
    document.body.append(element);

    element.setAttribute("value", "9");

    expect(element.shadowRoot?.querySelector("output")?.textContent).toBe("9");
  });
});

Diese Tests schützen Attribute, Property, Event-Payload, Shadow-DOM-Propagation und Rendering. Für Produktion ergänze Playwright-Tests für Tastatur, sichtbaren Fokus und echte React/Vue-Seiten.

Typische Fallstricke

Erstens: Shadow DOM und CSS. Ohne Theme-Hooks ist das Element starr, ohne Kapselung kann Host-CSS es zerstören.

Zweitens: Attribute/Property-Synchronisierung. Teste Attributänderung, Property-Setzen und internen Klick als getrennte Pfade.

Drittens: Event-Design. Name, detail, bubbles und composed sind öffentliche API.

Viertens: Accessibility. Ein Custom Tag hat keine automatische Semantik. Nutze native Buttons, Labels, Fokus und Tastaturbedienung.

Fünftens: SSR und Hydration. Vor customElements.define ist das Element noch nicht aktiv. Kritische Flows brauchen Fallbacks.

Sechstens: Versionierung. Attribute, CSS-Variablen, part und Event-Payload müssen dokumentiert und versioniert werden.

Fazit und Prüfnotiz

Web Components sind sinnvoll, wenn kleine UI-Verträge über Framework-Grenzen hinweg stabil bleiben sollen. Claude Code hilft am meisten, wenn der Prompt API, CSS-Grenzen, Events, Accessibility und Tests vorgibt. Lit kann später helfen, ersetzt aber diese Vertragsarbeit nicht.

ClaudeCodeLab unterstützt Teams bei Design-Systemen, CMS-Einbettung, internen Widgets und Claude-Code-Review-Prozessen. Für Arbeit an einem echten Repository ist die Training- und Beratungsseite der passende Einstieg.

Prüfnotiz: Das Beispiel validiert denselben quantity-stepper über Attribute, Property, Shadow-DOM-Buttons, quantity-change und React/Vue-Listener. Vor Produktion sollten Playwright-Tastaturtests und eine Fokusprüfung im echten Browser folgen.

#Claude Code #Web Components #Custom Elements #Shadow DOM #Lit
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.