Membangun Web Components dengan Claude Code
Bangun Web Components dengan Claude Code: Custom Elements, Shadow DOM, React/Vue, prompt review, dan testing.
Kenapa Claude Code cocok untuk Web Components
Web Components adalah standar browser untuk membuat elemen HTML yang bisa dipakai ulang. Custom Elements mendaftarkan tag sendiri, Shadow DOM mengisolasi markup dan CSS internal, sedangkan template atau slot membantu komposisi. Tujuannya bukan mengganti React atau Vue, tetapi membuat kontrak UI kecil yang bisa dipakai di banyak tempat.
Claude Code berguna karena risiko Web Components bukan hanya syntax. Risiko utamanya adalah kontrak yang tidak jelas: atribut apa yang publik, property JavaScript apa yang bisa diubah, event apa yang dipancarkan, apakah event keluar dari Shadow DOM, bagaimana styling bisa di-theme, dan test apa yang wajib ada. Jika semua batas ini masuk prompt, Claude Code bisa mengimplementasikan sekaligus mereview dengan lebih ketat.
Masa pernah mengalami kasus ketika komponen quantity selector dari React dashboard ingin dipakai di CMS dan halaman Vue. Memuat React runtime hanya untuk widget kecil terasa berlebihan, sedangkan copy-paste membuat perilaku berbeda. Setelah dipindah ke Web Component, tag yang sama bisa dipakai di HTML statis, CMS, React, dan Vue. Bagian yang perlu disiplin adalah sinkronisasi attribute/property, batas CSS, event, accessibility, dan versioning.
Artikel ini membuat quantity-stepper: implementasi minimal Custom Element, attribute monitoring, CustomEvent, Shadow DOM styling, penggunaan dari React/Vue, prompt review Claude Code, dan test Vitest. Untuk konteks sistem desain, baca juga panduan design system Claude Code.
Referensi resmi: MDN Web Components, MDN Using custom elements, dan MDN CustomEvent.
Use case yang tepat
Web Components paling cocok untuk UI kecil, mandiri, dan perlu hidup di beberapa stack. Jika fitur mengelola routing, state global, dan SSR kompleks, framework utama biasanya lebih tepat. Beri tahu Claude Code konteks pemakaian sebelum meminta implementasi.
| Use case | Kenapa cocok | Instruksi untuk Claude Code |
|---|---|---|
| Distribusi design system | React, Vue, dan static page memakai paket sama | token, ukuran, state, accessibility |
| Embed di CMS | Editor mendapat interaksi tanpa app runtime penuh | tidak bergantung CSS global, script aman |
| UI lintas framework | Membantu migrasi bertahap | atribut, event, kebijakan breaking change |
| Komponen form | quantity, rating, autocomplete bisa dipaketkan | label, error, keyboard, form behavior |
| Widget internal | Portal admin dan dashboard memakai tool sama | jangan simpan secret di atribut, log, versi |
Copy button, rating, quantity selector, search box kecil, dan help launcher adalah kandidat bagus. Checkout penuh atau editor kompleks biasanya terlalu besar untuk satu Web Component.
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"]
Implementasi Custom Element
Simpan kode ini sebagai quantity-stepper.ts. Komponen membaca atribut HTML, menormalkan angka, merefleksikan nilai, dan mengirim event 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, property, dan event
Attribute HTML selalu string. Property JavaScript bisa number atau object. Karena itu value="2" dan element.value = 2 perlu aturan sinkronisasi.
Implementasi ini mengamati value, step, dan label. Ketika attribute berubah, state internal dan render diperbarui. Ketika user klik, setter value memperbarui attribute, mengirim event, lalu render ulang.
const stepper = document.querySelector("quantity-stepper");
stepper?.addEventListener("quantity-change", (event) => {
const { value } = (event as CustomEvent<{ value: number }>).detail;
console.log("Selected quantity:", value);
});
Event adalah API publik. Nama quantity-change lebih jelas daripada change; payload { value: number } kecil dan stabil; bubbles: true serta composed: true membuat event bisa didengar di luar Shadow DOM.
Styling dengan Shadow DOM
Shadow DOM melindungi komponen dari CSS global, tetapi design system tetap butuh pintu masuk tema. Pakai CSS Custom Properties dan part.
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;
}
Instruksikan Claude Code agar tidak bergantung pada CSS global, tetapi tetap membuka hooks tema. Review juga focus ring, native button, label, dan aria-live. Untuk checklist lanjut, lihat panduan accessibility Claude Code.
Penggunaan di React dan Vue
Di React, gunakan ref dan addEventListener untuk CustomEvent.
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>
);
}
Di Vue, gunakan template ref dan bersihkan listener saat 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",
},
},
}),
],
});
Prompt review Claude Code
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.
Prompt ini membuat Claude Code fokus pada kontrak publik, bukan hanya tampilan. Untuk tim, simpan versi pendek di CLAUDE.md.
Testing
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");
});
});
Test melindungi kontrak publik: attribute, property, payload event, propagasi Shadow DOM, dan render. Untuk produksi, tambahkan Playwright untuk keyboard, focus visible, dan halaman React/Vue nyata.
Pitfall umum
Pertama, Shadow DOM dan CSS. Tanpa token, komponen sulit di-theme. Tanpa isolasi, CSS host bisa merusak widget.
Kedua, sinkronisasi attribute/property. Test perubahan attribute, perubahan property lewat JS, dan klik internal.
Ketiga, desain event. Nama event, detail, bubbles, dan composed adalah API publik.
Keempat, accessibility. Custom tag tidak otomatis punya semantik. Gunakan native button, label, focus ring, dan keyboard support.
Kelima, SSR dan hydration. Sebelum customElements.define berjalan, tag belum aktif. Flow penting perlu fallback.
Keenam, versioning. Attribute, CSS variables, part, dan event payload harus didokumentasikan dan mengikuti semver.
Ringkasan dan catatan validasi
Web Components cocok untuk kontrak UI kecil yang melewati batas framework. Claude Code paling efektif ketika prompt sudah berisi API publik, batas styling, event, accessibility, dan test. Lit bisa membantu komponen lebih besar, tetapi disiplin kontrak tetap wajib.
ClaudeCodeLab dapat membantu tim membuat design system, embed CMS, library widget internal, dan workflow review Claude Code. Untuk bekerja dengan repository nyata, lihat training dan konsultasi Claude Code.
Catatan validasi: contoh ini mengecek kontrak quantity-stepper lewat attribute, property, tombol Shadow DOM, event quantity-change, dan listener React/Vue. Sebelum produksi, tambahkan test keyboard dan focus di browser nyata.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.