Use Cases (Actualizado: 2/6/2026)

Web Components con Claude Code: guía práctica

Aprende a crear Web Components con Claude Code: Custom Elements, Shadow DOM, React/Vue, prompts de revisión y pruebas.

Web Components con Claude Code: guía práctica

Por qué usar Claude Code para Web Components

Web Components es un conjunto de APIs nativas del navegador para crear elementos HTML reutilizables. Custom Elements permite registrar una etiqueta propia, Shadow DOM separa la estructura y los estilos internos, y templates o slots ayudan a componer contenido. La ventaja no es reemplazar React o Vue, sino publicar una pieza pequeña de UI que pueda vivir en muchos entornos.

Claude Code funciona bien en este tipo de trabajo porque el riesgo no está solo en escribir el componente. El riesgo está en diseñar mal el contrato: qué atributos acepta, qué property expone, qué evento emite, cómo se tematiza desde fuera, cómo se comporta con teclado y qué pruebas protegen esos detalles. Si el prompt incluye esos límites, Claude Code puede implementar y revisar con más precisión.

Masa encontró este problema al intentar reutilizar un control de cantidad hecho para React dentro de un CMS y una pantalla Vue. Cargar React completo para un widget pequeño era excesivo, y copiar el código generaba diferencias de diseño. Convertirlo en Web Component permitió usar la misma etiqueta en HTML estático, CMS, React y Vue. Lo delicado fue mantener sincronizados atributos y properties, nombrar bien el evento y no romper los estilos por culpa de Shadow DOM.

En esta guía construiremos quantity-stepper: implementación mínima de Custom Element, observación de atributos, CustomEvent, estilos internos, uso desde React y Vue, prompt de revisión para Claude Code y pruebas con Vitest. Para convertirlo en una librería de equipo, conecta este enfoque con la guía de design system con Claude Code.

Consulta las referencias oficiales: MDN Web Components, MDN Using custom elements y MDN CustomEvent.

Casos de uso adecuados

Web Components encaja cuando necesitas un componente pequeño, estable y reutilizable en varios stacks. No es ideal para una pantalla completa con routing, estado global y SSR complejo. Antes de pedir la implementación, dile a Claude Code dónde se usará el componente y qué no debe asumir.

Caso de usoPor qué encajaQué pedir a Claude Code
Distribuir un design systemReact, Vue y páginas estáticas consumen el mismo paquetetokens, estados, tamaños, accesibilidad
Insertar en CMSEl editor puede añadir interacción sin cargar una app completano depender de CSS global, carga segura del script
UI común entre frameworksAyuda en migraciones gradualesatributos, eventos, política de breaking changes
Componentes de formularioCantidad, rating o autocompletado se empaquetan apartelabel, error, teclado, integración con formulario
Widgets internosPortales y dashboards reutilizan la misma piezasin secretos en atributos, logs, versión

Botones de copia, selectores de cantidad, rating, buscadores pequeños y launchers de ayuda son buenos candidatos. Un checkout entero o un editor complejo no lo son. Si el componente tiene demasiadas responsabilidades, Claude Code también tendrá más probabilidades de mezclar decisiones.

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"]

Implementación mínima de Custom Element

Guarda este archivo como quantity-stepper.ts. Lee atributos de HTML, normaliza números, refleja el valor actualizado y emite quantity-change cuando el usuario interactúa.

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 y eventos

Un atributo HTML siempre llega como texto. Una property de JavaScript puede ser número, booleano u objeto. Si el componente no define cómo sincronizar ambos caminos, la integración con frameworks se vuelve frágil.

En este ejemplo, value, step y label se observan con observedAttributes. Cuando cambia un atributo, el componente sincroniza estado interno y renderiza. Cuando el usuario hace clic, el setter de value actualiza el atributo, emite el evento y vuelve a renderizar.

const stepper = document.querySelector("quantity-stepper");

stepper?.addEventListener("quantity-change", (event) => {
  const { value } = (event as CustomEvent<{ value: number }>).detail;
  console.log("Selected quantity:", value);
});

El evento forma parte del contrato público. Usa un nombre específico, un payload pequeño y estable, y composed: true cuando el consumidor está fuera del Shadow DOM.

Estilos con Shadow DOM

Shadow DOM evita que el CSS global rompa el componente. Esa protección es útil en un CMS, pero también puede impedir el theming si no expones entradas claras.

Combina CSS Custom Properties para tokens y part para zonas internas concretas.

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;
}

Pide a Claude Code dos cosas a la vez: que el componente no dependa del CSS global y que exponga hooks de tema. Además, revisa foco visible, botones nativos, labels y cambios anunciados con aria-live. Para profundizar, consulta la guía de accesibilidad con Claude Code.

Uso desde React y Vue

En React, usa ref y addEventListener para escuchar el CustomEvent con seguridad.

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>
  );
}

En Vue, usa una referencia de plantilla y limpia el listener al desmontar.

<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 revisión 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.

Este prompt obliga a revisar contratos, no solo apariencia. Si el equipo creará más componentes, deja una versión corta en CLAUDE.md.

Pruebas

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");
  });
});

La prueba protege el contrato público: atributos, property, evento, propagación fuera de Shadow DOM y renderizado. En producción añade Playwright para teclado, foco visible y uso real dentro de React o Vue.

Errores frecuentes

Primero, CSS y Shadow DOM. Si dependes de CSS global, el host puede romper el widget. Si no expones tokens, el design system no puede tematizarlo.

Segundo, sincronización de atributo y property. Prueba actualización externa, actualización por JavaScript y clic interno.

Tercero, eventos. El nombre, detail, bubbles y composed son API pública. Cambiarlos exige versión mayor.

Cuarto, accesibilidad. Un custom tag no tiene semántica por sí solo. Usa controles nativos, label, foco visible y teclado.

Quinto, SSR e hydration. Antes de que customElements.define se ejecute, la etiqueta no está actualizada. Define fallback para flujos críticos.

Sexto, versionado. Documenta atributos, events, CSS variables y part en README o Storybook. Si lo publicas como paquete, usa semver.

Resumen y nota de validación

Web Components son una buena opción para contratos pequeños de UI que deben cruzar frameworks. Claude Code aporta valor cuando recibe el contrato completo: API pública, límites de CSS, eventos, accesibilidad y pruebas.

ClaudeCodeLab puede ayudar a convertir este patrón en design system, widgets internos, integración con CMS o workflow de revisión con Claude Code. Para trabajar sobre un repositorio real, revisa la página de formación y consultoría.

Nota de validación: el ejemplo comprueba el mismo contrato quantity-stepper desde atributos, property, botones dentro de Shadow DOM, evento quantity-change y consumo en React/Vue. Antes de producción conviene añadir pruebas de teclado y foco en navegador real.

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

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.