Use Cases (Diperbarui: 2/6/2026)

Membangun Web Components dengan Claude Code

Bangun Web Components dengan Claude Code: Custom Elements, Shadow DOM, React/Vue, prompt review, dan testing.

Membangun Web Components dengan Claude Code

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 caseKenapa cocokInstruksi untuk Claude Code
Distribusi design systemReact, Vue, dan static page memakai paket samatoken, ukuran, state, accessibility
Embed di CMSEditor mendapat interaksi tanpa app runtime penuhtidak bergantung CSS global, script aman
UI lintas frameworkMembantu migrasi bertahapatribut, event, kebijakan breaking change
Komponen formquantity, rating, autocomplete bisa dipaketkanlabel, error, keyboard, form behavior
Widget internalPortal admin dan dashboard memakai tool samajangan 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.

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

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.