Use Cases (Atualizado: 02/06/2026)

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.

Web Components com Claude Code: guia prático

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 usoPor que funcionaO que pedir a Claude Code
Distribuir design systemReact, Vue e páginas estáticas usam o mesmo pacotetokens, tamanhos, estados, acessibilidade
Embutir em CMSO editor ganha interação sem carregar app inteirasem CSS global, ordem segura de script
UI comum entre frameworksAjuda em migrações graduaisatributos, eventos, política de versão
Componentes de formulárioQuantidade, rating e autocomplete viram móduloslabel, erro, teclado, formulário
Widgets internosPortais e dashboards compartilham a mesma peçasem 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.

#Claude Code #Web Components #Custom Elements #Shadow DOM #Lit
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.