Claude Code로 Web Components 구현하기
Claude Code로 Web Components를 구현하는 실전 가이드. Custom Elements, Shadow DOM, React/Vue 연동, 테스트까지 다룹니다.
Claude Code와 Web Components가 잘 맞는 이유
Web Components는 브라우저 표준으로 재사용 가능한 HTML 요소를 만드는 기술입니다. Custom Elements는 직접 만든 태그를 등록하고, Shadow DOM은 내부 DOM과 CSS를 바깥 페이지에서 격리하며, template과 slot은 반복 구조를 안정적으로 만들게 해 줍니다. 핵심은 React나 Vue를 대체하는 것이 아니라, 여러 프레임워크에서 공유할 수 있는 작은 UI 계약을 만드는 것입니다.
Claude Code는 이 작업에 잘 맞습니다. Web Components는 코드 몇 줄로 시작할 수 있지만, 공개 API가 흐리면 금방 망가집니다. attribute와 property를 어떻게 맞출지, 이벤트 이름과 payload를 어떻게 정할지, Shadow DOM 스타일을 어디까지 열어둘지, 접근성 테스트를 어떻게 할지 먼저 정해야 합니다. 이 기준을 prompt에 넣으면 Claude Code가 단순 생성기가 아니라 구현자와 리뷰어 역할을 같이 할 수 있습니다.
Masa가 겪은 실제 문제는 React 관리 화면의 작은 수량 선택 컴포넌트를 CMS 페이지와 Vue 화면에도 쓰고 싶었던 상황입니다. React runtime을 CMS에 싣는 것은 과했고, 복사해서 다시 만들면 디자인과 이벤트가 달라졌습니다. Web Component로 분리하자 정적 HTML, CMS, React, Vue에서 같은 태그를 사용할 수 있었습니다. 다만 CSS 격리, 이벤트 설계, 버전 관리가 느슨하면 나중에 더 큰 비용이 생겼습니다.
이 글에서는 quantity-stepper를 예제로 Custom Element 최소 구현, attribute 감시, CustomEvent, Shadow DOM 스타일, React/Vue 사용법, Claude Code 리뷰 지시, Vitest 테스트를 한 번에 정리합니다. 디자인 시스템 전체 흐름은 Claude Code 디자인 시스템 가이드와 함께 보면 좋습니다.
공식 기준은 MDN Web Components, MDN Using custom elements, MDN CustomEvent를 확인하세요.
적합한 사용 사례
Web Components는 모든 UI에 맞는 선택지가 아닙니다. 페이지 전체 상태, 라우팅, 서버 렌더링 흐름을 소유하는 기능은 앱 프레임워크 안에 두는 편이 자연스럽습니다. 반대로 작고 독립적인 UI를 여러 환경에 배포해야 한다면 강력합니다.
| 사용 사례 | 적합한 이유 | Claude Code에 줄 조건 |
|---|---|---|
| 디자인 시스템 배포 | React, Vue, 정적 사이트가 같은 부품을 쓸 수 있음 | 토큰, 크기, 상태, 접근성 규칙 |
| CMS 임베드 | CMS에 작은 인터랙션을 넣어도 앱 전체가 필요 없음 | 전역 CSS 의존 금지, script 로딩 순서 |
| 여러 프레임워크 공통 UI | 마이그레이션 중 구 화면과 신 화면이 같은 태그 사용 | attribute API, 이벤트명, 버전 정책 |
| 폼 부품 | 수량, 평점, 자동완성 같은 입력 보조를 독립 배포 | label, 오류 상태, 키보드 조작 |
| 사내 위젯 | 관리자 화면과 포털에 같은 작은 도구 삽입 | secret 금지, 로그, 패키지 버전 |
복사 버튼, 평점 선택, 수량 조절, 검색 입력, 헬프 버튼은 좋은 후보입니다. 반면 전체 결제 페이지나 복잡한 에디터를 Web Component 하나로 만들면 책임이 너무 커집니다. Claude Code에게는 컴포넌트가 맡을 일과 맡지 않을 일을 함께 전달하세요.
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"]
Custom Element 최소 구현
아래 코드를 quantity-stepper.ts로 저장합니다. HTML attribute는 문자열이므로 내부에서 숫자로 정규화하고, 클릭으로 값이 바뀌면 attribute와 property를 맞춘 뒤 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, CustomEvent
attribute는 HTML 문자열이고 property는 JavaScript 값입니다. 이 차이를 무시하면 React에서는 값이 바뀌는데 CMS에서는 바뀌지 않거나, 화면은 갱신되지만 이벤트가 안 나가는 문제가 생깁니다.
이 구현은 observedAttributes로 value, step, label을 감시합니다. 외부에서 attribute가 바뀌면 내부 값을 동기화하고 다시 렌더링합니다. 내부 클릭은 value setter를 지나며 attribute 반영, 이벤트 발행, 렌더링을 같은 순서로 처리합니다.
const stepper = document.querySelector("quantity-stepper");
stepper?.addEventListener("quantity-change", (event) => {
const { value } = (event as CustomEvent<{ value: number }>).detail;
console.log("Selected quantity:", value);
});
이벤트 이름은 change보다 quantity-change처럼 구체적인 이름이 좋습니다. Shadow DOM 밖의 앱에서 이벤트를 받아야 하므로 bubbles: true와 composed: true도 공개 API로 봐야 합니다.
Shadow DOM 스타일링
Shadow DOM 내부에는 바깥 페이지의 CSS가 쉽게 들어오지 않습니다. 그래서 CMS나 오래된 사내 포털에 넣어도 안전하지만, 테마 변경 지점이 없으면 디자인 시스템과 맞추기 어렵습니다.
실무에서는 CSS Custom Properties와 part를 같이 씁니다. Custom Properties는 색상과 간격 같은 토큰을 전달하고, 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;
}
Claude Code에는 전역 CSS에 의존하지 말 것, 하지만 테마 hook은 공개할 것이라고 함께 지시하세요. focus ring을 없애지 않는지, label과 aria-live가 있는지도 Claude Code 접근성 가이드 기준으로 확인합니다.
React와 Vue에서 사용하기
React에서는 custom event를 ref로 구독하는 방식이 가장 예측 가능합니다.
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에서는 template ref와 lifecycle hook으로 처리합니다.
<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 리뷰 지시
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는 Claude Code가 외형보다 공개 계약을 검토하게 만듭니다. 팀에서는 CLAUDE.md에 축약해서 넣어 두면 새 컴포넌트 추가 때 기준이 흔들리지 않습니다.
테스트로 계약 보호하기
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");
});
});
테스트는 내부 HTML 전체보다 공개 API를 보호해야 합니다. attribute 변경, 클릭, 이벤트 payload, Shadow DOM 바깥 전파를 확인하면 실제 앱에 붙였을 때의 사고가 줄어듭니다.
흔한 함정
첫째, Shadow DOM과 CSS입니다. 전역 CSS에 기대면 호스트 페이지에 깨지고, 완전히 닫으면 테마 적용이 어렵습니다. CSS 변수와 part를 명시하세요.
둘째, attribute와 property 동기화입니다. HTML은 문자열이고 JS는 숫자나 객체가 될 수 있습니다. 세 경로가 같은 상태로 수렴하는지 테스트해야 합니다.
셋째, 이벤트 설계입니다. 이벤트명, detail 구조, composed 여부는 공개 API입니다. 나중에 바꾸면 breaking change입니다.
넷째, 접근성입니다. custom tag 자체에는 버튼 의미가 없습니다. 내부에 native button을 쓰고 label, focus, keyboard, live region을 확인하세요.
다섯째, SSR과 hydration입니다. 정의 파일이 로드되기 전까지는 업그레이드되지 않은 HTML입니다. 결제나 가입 흐름에는 fallback이 필요합니다.
여섯째, 버전 관리입니다. attribute, CSS 변수, part, 이벤트 payload는 README나 Storybook에 기록하고 semver로 관리합니다.
정리와 검증 메모
Web Components는 프레임워크 대체제가 아니라 여러 환경에서 공유할 작은 UI 계약입니다. Claude Code에는 공개 API, 스타일 경계, 이벤트, 접근성, 테스트를 먼저 주고 구현을 맡기는 방식이 가장 안정적입니다. Lit을 쓰더라도 이 원칙은 그대로 유지됩니다.
ClaudeCodeLab은 Web Components 기반 디자인 시스템, CMS 임베드, 사내 위젯 라이브러리, Claude Code 리뷰 워크플로를 실제 저장소 기준으로 정리할 수 있습니다. 팀 도입이 필요하면 Claude Code 교육 및 상담을 확인하세요.
검증 메모: 이 글의 코드는 quantity-stepper 하나로 attribute, property, Shadow DOM 버튼, quantity-change 이벤트, React/Vue 구독을 확인하도록 구성했습니다. 운영 전에는 Playwright 키보드 테스트와 실제 브라우저 focus 표시 확인을 추가하는 것을 권장합니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.