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.
Artigos relacionados
Propostas e briefings de agência web em metade do tempo com o Claude Code
Diretor de agência web: monte propostas e briefings em metade do tempo com o Claude Code. Com prompt, checklist e script prontos.
Web scraping seguro com Claude Code: Fetch, Playwright e logs de auditoria
Implemente web scraping seguro com Claude Code: robots.txt, Fetch, Playwright, CSV e auditoria.
Web Audio API com Claude Code: onda, gravação e medidor de volume
Implemente Web Audio API com Claude Code: onda, gravação, medidor, cleanup e testes em React/TypeScript.
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.