Use Cases (Updated: 6/2/2026)

Building Web Components with Claude Code: Practical Guide

Build Web Components with Claude Code: Custom Elements, Shadow DOM styling, React/Vue usage, reviews, and tests.

Building Web Components with Claude Code: Practical Guide

Why Web Components fit Claude Code

Web Components are a browser-native way to create reusable HTML elements. Custom Elements register your own tag name, Shadow DOM isolates markup and styles, and templates help you repeat internal structure without tying the component to a framework. In practice, this means you can ship a small UI contract that works in React, Vue, a CMS page, or plain HTML.

Claude Code is useful here because Web Components fail when the contract is vague. A component needs a clear public API, predictable attributes, stable events, documented styling hooks, and tests that protect those choices. If you give Claude Code those boundaries up front, it can implement, review, and test the component instead of generating a pretty but fragile snippet.

Masa’s first mistake with this pattern was trying to reuse a React-only button and form set inside a CMS page. The visual design was right, but the CMS should not load a React runtime just for a quantity selector and a CTA widget. Moving the small parts to Web Components made them usable from a static article, a Vue admin screen, and the original React dashboard. The painful parts were not the tags themselves; the painful parts were Shadow DOM styling, event naming, and keeping attributes and properties synchronized.

This guide stays practical. You will build a copyable Custom Element, add attribute observation, emit a typed CustomEvent, expose styling hooks, consume the element from React and Vue, ask Claude Code for a critical review, and protect the contract with Vitest. For broader UI governance, pair this with the Claude Code design system guide.

Use official references as the source of truth: MDN Web Components, MDN Using custom elements, and MDN CustomEvent.

Use cases that justify the pattern

Web Components are not a replacement for every React or Vue component. They are strongest when a small, self-contained UI needs to survive across different rendering environments. Tell Claude Code those environments before implementation; otherwise it may assume a single framework and design the API too narrowly.

Use caseWhy Web Components helpWhat to tell Claude Code
Design system distributionOne package can serve React, Vue, static pages, and docsTokens, sizes, states, accessibility rules
CMS embeddingEditors can add interactive UI without owning an app buildNo global CSS dependency, safe script loading
Shared UI across migrationsOld and new frontends can use the same element during migrationAttribute API, event names, version policy
Form controlsQuantity, rating, autocomplete, and validation helpers can be reusedLabeling, error states, keyboard behavior
Internal widgetsAdmin portals and BI pages can embed the same toolNo secrets in attributes, logging, package version

Good examples include quantity steppers, rating controls, copy buttons, search boxes, product badges, and help launchers. Poor examples include full-page routing, complex application state, or server-component-heavy flows. If the feature owns the page, keep it in the app framework. If it is a small contract that many surfaces need, Web Components are worth considering.

The architecture is simple: design tokens feed a browser-native component package, and each frontend consumes that package without rewriting the behavior.

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

Minimal Custom Element implementation

Create quantity-stepper.ts. The element accepts string attributes from HTML, normalizes values internally, reflects changes back to the value attribute, and emits a custom event whenever the user changes the value.

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

Use it from plain HTML like this. Custom Element names must contain a hyphen, which is why quantity-stepper is valid and stepper is not.

<script type="module" src="/src/quantity-stepper.ts"></script>

<quantity-stepper value="2" step="1" label="Seats"></quantity-stepper>

Do not ask Claude Code to make the component “more powerful” before the contract is stable. First decide whether it owns form submission, whether it only assists a larger form, and which attributes, properties, events, and CSS hooks are public.

Attribute observation and CustomEvent design

The first beginner trap is the difference between attributes and properties. Attributes are strings in HTML. Properties are JavaScript values on the DOM object. value="2" is a string attribute, while element.value = 2 can be a number. If the component does not define how both paths work, React and Vue integrations will drift.

The implementation above watches value, step, and label through observedAttributes. When an attribute changes, attributeChangedCallback syncs internal state and re-renders. Internal button clicks go through the value setter, which reflects the value attribute, emits quantity-change, and re-renders.

Ask Claude Code to verify three event rules:

  • The event name is specific. quantity-change is clearer and safer than a generic change.
  • The detail payload is minimal and stable. Here { value: number } is enough.
  • The event crosses Shadow DOM when external apps need it. That requires bubbles: true and composed: true.

This small listener is enough to test the public event manually:

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

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

Document the source of truth. In this guide, external markup uses attributes, JavaScript can use the value property, and consumers listen for quantity-change. Treat that as a versioned public API.

Styling Shadow DOM safely

Shadow DOM changes how CSS behaves. Global selectors such as .button or button do not normally style the internals of a shadow tree. That isolation makes the component safer in a CMS, but it can also make theming difficult if you do not expose hooks.

The practical pattern is to combine CSS Custom Properties with part. Custom Properties pass tokens such as colors and spacing into the component. part exposes selected internal elements for controlled external styling.

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

Give Claude Code both constraints: the component must not depend on global CSS, and it must expose deliberate theme hooks. If you only isolate styles, the component becomes rigid. If you only rely on global CSS, it becomes fragile.

Accessibility is part of this styling work. Do not remove focus rings. Keep a visible target size. Use native button elements inside the shadow tree. Provide labels and live updates when values change. The Claude Code accessibility guide is a useful companion for those checks.

Using the element from React and Vue

Web Components are browser-native, so React and Vue can render them. The friction is in events and TypeScript declarations, not in the HTML tag itself.

In React, a reliable approach is to use a ref and subscribe with addEventListener.

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

In Vue, use a template ref and clean up the listener on 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>

If Vue warns that the tag is unresolved, configure the compiler to treat it as a Custom Element.

import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag === "quantity-stepper",
        },
      },
    }),
  ],
});

Claude Code review prompt

A vague prompt creates a vague component. Give Claude Code the allowed files, the public API, accessibility rules, and the checks it must perform before finishing.

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

For a review-only pass, make the tone critical and bug-focused:

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.

This turns Claude Code into a reviewer of the component contract, not just a code generator. Put the shorter version into CLAUDE.md if your team will add more elements later.

Testing the contract

Use tests to protect the public contract rather than snapshots of the full internal HTML. The important questions are: does the element reflect attributes, does it update on click, does it emit the right event, and can that event leave Shadow DOM?

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

For production, add Playwright checks for keyboard behavior, focus visibility, and the same component inside a real React or Vue page. Unit tests catch the API; browser tests catch integration and accessibility regressions.

Pitfalls to avoid

The first pitfall is Shadow DOM styling. If you assume global CSS will theme the component, the CMS version will look wrong. If you skip Shadow DOM entirely, existing page styles can leak in and break the widget. Decide on CSS Custom Properties and part names before shipping.

The second pitfall is attribute/property synchronization. HTML attributes are strings; JavaScript properties can be numbers, booleans, objects, or functions. Ask Claude Code to test attribute updates, property updates, and internal clicks as three separate paths that must converge on the same state.

The third pitfall is event design. Generic events such as change are easy to misunderstand. A domain event such as quantity-change is easier to document and version. If the event must be visible outside Shadow DOM, composed: true is part of the API.

The fourth pitfall is accessibility. A custom tag has no built-in semantics just because its name sounds interactive. Use native controls inside, keep labels, preserve focus styles, support keyboard operation, and announce value changes where useful.

The fifth pitfall is SSR and hydration. A Custom Element is inert until its definition loads on the client. In Astro, Next.js, or any server-rendered page, decide what users see before the module loads. Critical purchase or signup flows need a fallback or a disabled state.

The sixth pitfall is versioning. Changing an event payload from { value } to { quantity } is a breaking change. Treat attributes, properties, CSS variables, part names, and events as public API. Document them and use semantic versioning if the component is packaged.

Summary and validation note

Web Components are not a framework replacement. They are a good way to publish small UI contracts across React, Vue, CMS pages, static HTML, and internal tools. Claude Code works best when you give it the contract first: public API, styling boundaries, events, accessibility rules, and tests.

ClaudeCodeLab can help teams turn these patterns into a design system, CMS embedding strategy, internal widget library, or Claude Code review workflow. For hands-on help with a real repository, use the Claude Code training and consultation page.

Validation note: the sample code in this article is structured so the same quantity-stepper contract can be checked through attributes, Shadow DOM buttons, the quantity-change event, and React/Vue listeners. Before production, add Playwright keyboard tests and a real-browser focus check around the unit tests shown above.

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

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.