Use Cases (更新: 2026/6/2)

Claude CodeでWeb Componentsを実装する実践ガイド

Claude CodeでWeb Componentsを設計・実装・レビューする実践ガイド。React/Vue連携、Shadow DOM、テストまで解説。

Claude CodeでWeb Componentsを実装する実践ガイド

Web ComponentsをClaude Codeで作る理由

Web Componentsは、ブラウザ標準だけで独自のHTML要素を作るための技術です。Custom Elementsは独自タグを登録する仕組み、Shadow DOMは部品の内部DOMとCSSを外側から分離する仕組み、HTML Templatesは再利用しやすい断片を用意する仕組みです。この記事では、これらをまとめて「ReactやVueに閉じないUI部品を作る方法」として扱います。

Claude Codeと相性が良いのは、Web Componentsの実装が「仕様」「境界」「テスト観点」を明確にしないと壊れやすいからです。人間が要件と失敗例を先に書き、Claude Codeに実装、レビュー、テスト追加を任せると、単なるコード生成ではなくチームの部品設計に近い作業になります。

Masaが最初に失敗したのは、React管理画面で使っていたボタン群をそのままCMSにも埋め込もうとしたことです。見た目は似ているのに、CMS側ではReactのランタイムを載せたくない。フォーム部品だけ再利用したい。そこでWeb Componentsに切り出すと、CMS、静的HTML、Vue画面、React画面で同じUIを使い回せました。ただしShadow DOMのCSS、イベント名、属性とプロパティの同期を雑にすると、あとでかなり苦労します。

この記事は初心者向けですが、短い紹介では終わりません。Custom Elementの最小実装、属性監視、CustomEvent、Shadow DOMスタイル、React/Vueからの利用、Claude Codeへのレビュー指示、Vitestテストまで、コピペして検証できる形で進めます。UI全体の設計を固めたい場合は、先にClaude Codeデザインシステム構築ガイドも読むと判断しやすくなります。

公式仕様はMDN Web Components、Custom Elementsの詳細はMDN Using custom elements、イベントはMDN CustomEventを基準に確認してください。

向いているユースケース

Web Componentsは、すべてのUIを置き換える万能手段ではありません。強いのは「小さく閉じたUIを、複数の環境へ同じ契約で配る」場面です。Claude Codeには、最初にどの環境で使うかを渡すと実装の精度が上がります。

ユースケースWeb Componentsが効く理由Claude Codeに渡す観点
デザインシステム配布React、Vue、静的サイトで同じ部品を使えるトークン、サイズ、状態、a11y要件
CMS埋め込みWordPressやHeadless CMSに小さなUIを追加できる外部CSSに依存しないこと、script読み込み順
複数フレームワーク共通UIReact移行中でも旧画面と新画面で共用できる属性API、イベント名、破壊的変更の扱い
フォーム部品入力補助、数量選択、住所補完などを単体で配れるlabel、エラー、キーボード操作、フォーム連携
社内ウィジェット管理画面、BI、社内ポータルに同じ部品を置ける認証情報を持たせないこと、ログ、バージョン

たとえば数量入力、評価スター、コピー用ボタン、検索ボックス、チャット起動ボタンはWeb Componentsに向いています。一方、ページ全体の状態管理、複雑なルーティング、サーバーコンポーネント前提の画面は、ReactやVueのアプリとして作るほうが自然です。

概念図にすると、Web Componentはフレームワークの中に入る部品ではなく、ブラウザ標準の層に置く小さな契約です。

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

最小のCustom Element実装

まずは外部ライブラリなしで動く数量ステッパーを作ります。quantity-stepper.tsとして保存し、ViteやAstroなどのフロントエンド環境で読み込めます。重要なのは、属性を文字列として受け取り、内部では数値に正規化し、変更時にイベントを外へ出すことです。

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

HTMLからは次のように使えます。ブラウザはタグ名にハイフンがあるものだけをCustom Elementとして登録できます。

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

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

この段階でClaude Codeに「さらにリッチにして」と頼むと、責務が膨らみがちです。まずは値、増減、ラベル、イベント、スタイルの境界を固定します。フォーム送信まで担当させるのか、見た目だけの入力補助にするのかも先に決めてください。

属性監視とCustomEventの設計

Web Componentsで初心者がつまずくのは、属性とプロパティの違いです。属性はHTML上の文字列です。プロパティはJavaScriptオブジェクト上の値です。value="2"は文字列ですが、element.value = 2は数値にできます。この差を曖昧にすると、ReactやVueから利用したときに「表示だけ変わる」「イベントだけ飛ぶ」「状態が戻る」というバグが出ます。

上の実装では、observedAttributesvaluesteplabelを監視しています。外側から属性が変わったらattributeChangedCallbackが呼ばれ、内部状態を同期して再描画します。内部のボタン操作ではvalueセッターを通し、属性へ反映してからquantity-changeイベントを出します。

イベント設計では、次の3点をClaude Codeに必ず確認させます。

  • イベント名がドメイン固有か。changeだけではネイティブフォームイベントと混ざるため、quantity-changeのように具体化する。
  • detailに必要最小限の値が入っているか。今回は{ value: number }だけで十分。
  • Shadow DOMの外へ伝える必要がある場合、bubbles: truecomposed: trueが設定されているか。

単体でイベントだけ確認するコードは次の通りです。

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

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

属性とプロパティを両方サポートするなら、どちらを正とするかを決めます。この記事の実装では、外部入力は属性、JavaScriptからの操作はvalueプロパティ、通知はquantity-changeです。これをREADMEやStorybookにも書くと、他チームが迷いません。

Shadow DOMスタイルの扱い

Shadow DOMは便利ですが、CSSの直感が変わります。外側の.buttonbutton { ... }はShadow DOM内へ基本的に入りません。これは部品を壊れにくくする一方、デザインシステムのテーマを適用したいときに困ります。

実務では、CSS Custom Propertiesとpartを組み合わせます。CSS Custom Propertiesは--quantity-accentのような変数で色や余白を渡す方法です。partはShadow DOM内の要素に外側から限定的にスタイルを当てるための出口です。

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

Claude Codeには「外側のCSSに依存しないこと」と「テーマ変更用のCSS Custom Propertiesを公開すること」を同時に指示します。片方だけだと、部品が閉じすぎるか、逆に外側のCSSで壊れやすくなります。

アクセシビリティもスタイルの一部として見ます。フォーカスリングを消さない、aria-labelを用意する、数値の変化をaria-liveで伝える、ボタンの見た目だけで意味を伝えない。このあたりはClaude Codeアクセシビリティ実装ガイドの観点とも重なります。

ReactとVueから利用する

Web Componentsはブラウザ標準なので、ReactやVueからも利用できます。ただし、イベントの受け方とTypeScriptの型定義はフレームワークごとに少し違います。

Reactでは、CustomEventを通常のonQuantityChangeのように受けられない場面があります。確実なのはrefでDOM要素を取り、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>
  );
}

Vueではrefとライフサイクルで同じように購読できます。プロジェクト設定によっては、VueコンパイラにCustom Elementとして扱うタグを教える必要があります。

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

Vue 3で警告が出る場合は、Vite設定に次のような指定を加えます。

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

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

Claude Codeに渡すレビュー指示

Claude Codeに「Web Componentを作って」とだけ頼むと、見た目は動いても公開APIが弱い部品になりがちです。依頼文には、使う場所、変更してよいファイル、外部へ公開する属性、イベント、アクセシビリティ、テストコマンドを入れます。

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.

このように依頼すると、Claude Codeが「実装者」だけでなく「公開部品のレビュアー」として働きます。社内で使う場合は、このプロンプトをCLAUDE.mdに短く残すと、別メンバーが同じ基準で部品を追加できます。

テストを書く

Web Componentsはブラウザ標準なので、最低限の単体テストで契約を守れます。ここではVitestとhappy-domを想定します。E2Eまで必要な場合はPlaywrightでキーボード操作と実ブラウザの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");
  });
});

テスト対象は見た目のスナップショットより、公開契約に寄せます。属性を変えたら表示が変わるか、クリックで値が変わるか、イベントが外へ出るか、detailの形が壊れていないか。この4つを押さえるだけで、CMSやReact/Vueへ配った後の事故がかなり減ります。

よくある落とし穴

1つ目の落とし穴は、Shadow DOMとCSSの境界です。外側のCSSが効かないことを知らずに作ると、CMSに埋め込んだ瞬間に「テーマカラーが変えられない」と言われます。逆にShadow DOMを使わずに作ると、既存サイトの.buttoninputスタイルに巻き込まれます。CSS Custom Propertiesとpartを公開する方針を最初に決めましょう。

2つ目は、属性とプロパティの同期です。HTML属性は文字列、JavaScriptプロパティは任意の型です。valueを属性からだけ読むのか、プロパティでも更新できるのかを曖昧にすると、フレームワーク連携で壊れます。Claude Codeには「属性変更、プロパティ変更、内部クリックの3経路で同じ状態になるか」をテストさせます。

3つ目は、イベント設計です。changeclickのような一般名だけに頼ると、利用側で意図が読めません。Shadow DOM内で発火したイベントを外側へ届けるならcomposed: trueも必要です。イベント名、detail、バブリングの有無は公開APIです。後から変えると破壊的変更になります。

4つ目は、アクセシビリティです。カスタム要素は名前だけではボタンや入力欄として認識されません。内部にネイティブbuttonを使う、aria-labelを渡す、フォーカスが見える、キーボードだけで操作できる、値の変化がスクリーンリーダーに伝わる。この基本をClaude Codeのレビュー条件に入れてください。

5つ目は、SSRとhydrationです。Web Componentsはクライアント側でcustomElements.defineされるまで未定義要素です。AstroやNext.jsでSSRする場合、初期HTMLは出てもイベントはまだ動きません。重要なフォーム送信や購入ボタンに使うなら、読み込み前の表示、無効状態、フォールバックを決めます。

6つ目は、バージョン管理です。quantity-changedetail{ value }から{ quantity }へ変えるだけでも利用側は壊れます。npm packageとして配るならsemverを守り、StorybookやREADMEに公開属性、CSS変数、イベントを明記します。Claude Codeにリリースノートまで生成させると、変更の危険度を見落としにくくなります。

まとめと検証メモ

Web Componentsは、ReactやVueの代替ではなく、複数環境にまたがる小さなUI契約を作るための選択肢です。Claude Codeを使うなら、実装を丸投げするより、公開API、CSSの境界、イベント、アクセシビリティ、テストを先に書いてから作らせるほうが安定します。

ClaudeCodeLabでは、Web Componentsを含むデザインシステム化、CMS埋め込み、社内ウィジェット化、Claude Codeのレビュー運用を相談できます。チームで標準部品を整えたい場合はClaude Code研修・相談で、実際のリポジトリを見ながら導入手順とレビュー基準を作るのが一番早いです。

検証メモ: この記事のコードは、quantity-stepperの属性監視、Shadow DOM内ボタン、quantity-changeイベント、React/Vueからの購読を同じ契約で確認できるように構成しました。公開前チェックでは、コードフェンス、内部リンク、公式リンク、本文量、updatedDate、heroImageを確認対象にしています。実プロジェクトへ入れる場合は、ここにPlaywrightのキーボード操作テストと、実ブラウザでのフォーカス表示確認を追加してください。

#Claude Code #Web Components #Custom Elements #Shadow DOM #Lit
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。