Web Components com Claude Code: guia prático
Crie Web Components com Claude Code: Custom Elements, Shadow DOM, uso em React/Vue, prompts de revisão e testes.
Por que usar Claude Code com Web Components
Web Components são APIs nativas do navegador para criar elementos HTML reutilizáveis. Custom Elements registra uma tag própria, Shadow DOM isola marcação e CSS internos, e templates ou slots ajudam a compor a interface. O objetivo não é substituir React ou Vue, e sim publicar um contrato pequeno de UI que funcione em vários ambientes.
Claude Code ajuda porque Web Components quebram quando o contrato fica vago. Quais atributos são públicos? Qual property JavaScript pode ser alterada? Qual evento é emitido? Ele atravessa o Shadow DOM? Quais estilos podem ser sobrescritos? Quais testes protegem isso? Quando essas perguntas entram no prompt, Claude Code implementa e revisa com menos suposição.
Masa enfrentou esse problema ao tentar levar um seletor de quantidade feito para React para um CMS e para uma tela Vue. Carregar React inteiro para um widget pequeno era pesado, e copiar o código criava divergência. Como Web Component, a mesma tag funcionou em HTML estático, CMS, React e Vue. As partes delicadas foram sincronização de atributo/property, estilo com Shadow DOM, evento público e versionamento.
Neste guia vamos criar quantity-stepper: implementação mínima de Custom Element, observação de atributos, CustomEvent, estilo, uso em React e Vue, prompt de revisão para Claude Code e teste com Vitest. Para transformar isso em padrão de equipe, leia também o guia de design system com Claude Code.
Use as referências oficiais: MDN Web Components, MDN Using custom elements e MDN CustomEvent.
Casos de uso ideais
Web Components fazem sentido para UI pequena, isolada e reutilizável em vários stacks. Para uma página completa com roteamento, estado global e SSR complexo, o framework principal costuma ser melhor. Antes de implementar, diga a Claude Code onde o componente será usado e quais responsabilidades ficam fora.
| Caso de uso | Por que funciona | O que pedir a Claude Code |
|---|---|---|
| Distribuir design system | React, Vue e páginas estáticas usam o mesmo pacote | tokens, tamanhos, estados, acessibilidade |
| Embutir em CMS | O editor ganha interação sem carregar app inteira | sem CSS global, ordem segura de script |
| UI comum entre frameworks | Ajuda em migrações graduais | atributos, eventos, política de versão |
| Componentes de formulário | Quantidade, rating e autocomplete viram módulos | label, erro, teclado, formulário |
| Widgets internos | Portais e dashboards compartilham a mesma peça | sem secrets em atributos, logs, versão |
Botões de copiar, seletores de quantidade, rating, busca simples e botões de ajuda são bons candidatos. Checkout completo, editor rico ou fluxo com muitas regras de negócio normalmente não são.
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"]
Implementação mínima
Salve como quantity-stepper.ts. O componente lê atributos HTML, normaliza números, reflete valor atualizado e emite 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>
Atributos, properties e eventos
Atributo HTML é string. Property JavaScript pode ser número. Por isso value="2" e element.value = 2 precisam de uma regra de sincronização.
O exemplo observa value, step e label. Quando um atributo muda, o estado interno é atualizado. Quando o usuário clica, o setter de value atualiza o atributo, emite evento e renderiza.
const stepper = document.querySelector("quantity-stepper");
stepper?.addEventListener("quantity-change", (event) => {
const { value } = (event as CustomEvent<{ value: number }>).detail;
console.log("Selected quantity:", value);
});
O evento também é API pública. quantity-change é mais claro que change; { value: number } é um payload pequeno; bubbles: true e composed: true permitem que React, Vue ou CMS escutem fora do Shadow DOM.
Estilo com Shadow DOM
Shadow DOM protege o componente contra CSS global, mas você ainda precisa abrir pontos controlados para tema. Use CSS Custom Properties e 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;
}
Peça a Claude Code para não depender de CSS global e, ao mesmo tempo, expor hooks de tema. Revise foco visível, botões nativos, labels e aria-live. O guia de acessibilidade com Claude Code ajuda nessa checagem.
Uso em React e Vue
Em React, use ref e addEventListener para receber o 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>
);
}
Em Vue, use template ref e limpe o listener no 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 de revisão para 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.
Esse prompt coloca Claude Code na função de revisor de contrato público, não apenas de gerador de UI. Para uso em equipe, coloque a versão curta em CLAUDE.md.
Testes
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");
});
});
O teste protege o contrato: atributo, property, evento, propagação pelo Shadow DOM e renderização. Em produção, adicione Playwright para teclado, foco visível e páginas reais em React/Vue.
Armadilhas comuns
Primeira: Shadow DOM e CSS. Sem tokens, o componente não segue tema. Sem isolamento, o CSS do host quebra o widget.
Segunda: sincronização attribute/property. Teste mudança externa, mudança por JavaScript e clique interno.
Terceira: evento. Nome, detail, bubbles e composed são API pública.
Quarta: acessibilidade. Custom tag não tem semântica automática. Use botões nativos, label, foco e teclado.
Quinta: SSR e hydration. Antes de customElements.define, a tag ainda não está ativa. Fluxos críticos precisam de fallback.
Sexta: versionamento. Atributos, variáveis CSS, part e payload do evento precisam de documentação e semver.
Resumo e nota de validação
Web Components são úteis para contratos pequenos de UI que cruzam frameworks. Claude Code ajuda quando recebe API pública, limites de CSS, eventos, acessibilidade e testes desde o início. Lit pode simplificar componentes maiores, mas não substitui essa disciplina.
ClaudeCodeLab ajuda equipes com design system, CMS, widgets internos e workflow de revisão com Claude Code. Para trabalhar com um repositório real, veja a página de treinamento e consultoria.
Nota de validação: o exemplo verifica o mesmo contrato quantity-stepper por atributos, property, botões Shadow DOM, evento quantity-change e consumo em React/Vue. Antes de produção, adicione testes de teclado e foco em navegador real.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.