Web Components入門: Custom ElementsとShadow DOMで作る使い回せるUI部品
Reactにもう疲れた人向けに、ブラウザ標準のWeb Componentsを実例で解説。Custom Elementsの作り方、Shadow DOMのカプセル化、slot、Litでの書き方まで、コピペで動くコード付き。
「このボタン、CMS側でも使いたいんですけど」
そう言われたとき、僕の管理画面はReactで動いていました。きれいなボタンが揃っている。でもCMSは別物で、Reactのランタイムをまるごと載せるなんて誰も望んでいない。ボタン1個のために数百KBのJavaScriptを足すんですか、と。
しかたなくCSSをコピペして似たボタンを作りました。3週間後、デザイナーが角丸を変えました。管理画面とCMSで、微妙に違う角丸のボタンが2つ並びました。これが、同じUIを2回書いた人間の末路です。
このとき助けてくれたのが Web Components でした。ブラウザ標準だけで「<my-button> っていう自分専用のタグ」を作れる仕組みです。React画面でもVue画面でもCMSでも、<my-button> と書けば同じボタンが出る。今日はこれを、実際に動くコードを触りながら覚えていきます。
この記事の要点
- Web Components は、ブラウザ標準だけで自作のHTMLタグを作る技術。React/Vueに依存しないので、どの環境でも同じ部品を使い回せる。
- 中身は3つの柱: Custom Elements(独自タグの登録)、Shadow DOM(中身のCSSとDOMを外から隔離するカプセル化)、slot/template(中身を差し込む穴)。
- ハイフン入りのタグ名(
quantity-stepper)が必須。<quantity>のような1単語タグは登録できない。 - 素のJSだと記述が長い。Lit(公式の薄いライブラリ)を使うと、宣言的に短く書けて実務向き。
- Reactなどと「置き換え」ではなく「共存」できる。アプリ全体ではなく、小さく閉じた部品から始めるのがコツ。
Web Componentsって、結局なに?
ひとことで言うと、自分でHTMLタグを作る仕組みです。
<video> や <details> はブラウザが用意したタグですよね。クリックすると勝手に折りたたまれたり、中にUIを持っていたりする。あれと同じノリで、<user-card> や <star-rating> みたいな「中身も振る舞いも自分で決めたタグ」を作れる。それがWeb Componentsです。
仕組みは3本柱でできています。最初に名前だけ覚えておけば十分です。
| 柱 | 役割 | 身近な例え |
|---|---|---|
| Custom Elements | 独自タグを定義・登録する | 新しい家電を「これは扇風機です」と登録する |
| Shadow DOM | 部品の内部DOM/CSSを外から隔離する | 家電のフタの中。外から勝手にいじれない |
| slot / template | 中身を差し込む穴・部品の雛形 | 写真立て。枠は固定、写真は差し替え自由 |
一番ありがたいのが2つ目の Shadow DOM、いわゆるカプセル化です。これがあると、外側のサイトに button { color: red } みたいな乱暴なCSSがあっても、部品の中のボタンは染まりません。逆に、部品の中のCSSが外に漏れて他を壊すこともない。冒頭で僕が苦しんだ「CSSが混ざる地獄」は、これで根本的に消えます。
最初に断っておくと、これはReactの代わりではありません。ページ全体の状態管理や複雑なルーティングは、今までどおりReactやVueの仕事です。Web Componentsが輝くのは、小さく閉じたUIを、いろんな環境に同じ形で配りたいとき。住み分けの話です。
最小のCustom Elementを作る
説明より手を動かしましょう。外部ライブラリなしで、数量を増減するステッパー(- 2 + みたいなやつ)を作ります。quantity-stepper.ts として保存して、ViteやAstroのフロント環境で読み込めます。
ポイントは3つだけ。①HTMLElement を継承する、②attachShadow で中身を隔離する、③customElements.define で名前を登録する。
const toNumber = (value: string | null, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
class QuantityStepper extends HTMLElement {
// 監視したい属性をここに並べる。変わると attributeChangedCallback が飛ぶ
static observedAttributes = ["value", "step", "label"];
#root = this.attachShadow({ mode: "open" }); // ← Shadow DOMで中身を隔離
#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, // ← Shadow DOMの壁を越えて外まで届かせる
}),
);
}
#render() {
const label = this.getAttribute("label") || "Quantity";
this.#root.innerHTML = `
<style>
:host { 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, #cbd5e1);
border-radius: 8px;
overflow: hidden;
}
button {
min-width: 2.5rem; min-height: 2.5rem;
border: 0; background: transparent;
color: var(--quantity-accent, #2563eb);
font: inherit; cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--quantity-accent, #2563eb);
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" 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" 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>
ここで一番つまずくポイントを先に潰しておきます。タグ名は必ずハイフンを含めること。<quantity-stepper> はOK、<quantity> はNGです。これはブラウザの仕様で、ハイフンの有無で「標準タグ」と「自作タグ」を区別しているからです。1単語のタグ名を付けて「動かない…」と30分溶かしたのは、僕です。
Shadow DOMのカプセル化と落とし穴
Shadow DOM のおかげで部品は壊れにくくなります。でも、その代償としてCSSの直感が一回ひっくり返ります。
具体的にはこう変わります。外側のCSSが、部品の中に入りません。サイト全体に button { background: red } と書いても、quantity-stepper の中のボタンは赤くなりません。これは事故防止として最高なんですが、「じゃあテーマカラーをどう変えるの?」という新しい困りごとが生まれます。
答えは穴を2種類だけ開けておくことです。
1つ目が CSS Custom Properties(CSS変数)。--quantity-accent のような変数は Shadow DOM の壁をすり抜けて中に届きます。上のコードで var(--quantity-accent, #2563eb) と書いたのがこれ。外から色を渡す専用の窓口です。
2つ目が part。中の要素に part="button" と印を付けておくと、外側から ::part(button) でその要素だけスタイルを当てられます。「ここだけは外からいじってOK」という出口を、自分で選んで開ける感覚です。
quantity-stepper {
--quantity-accent: #16a34a; /* 変数で色を流し込む */
--quantity-border: #94a3b8;
}
quantity-stepper::part(button) {
font-weight: 700; /* part で開けた出口だけ直接いじる */
}
この「変数とpartだけ公開する」方針を最初に決めるかどうかで、後の運用が天と地ほど変わります。何も公開しないと部品が閉じすぎて誰も使えない。逆に Shadow DOM を使わずに作ると、既存サイトの .button や input のスタイルに巻き込まれて崩れる。中間を狙うのがコツです。
なお mode: "open" で作ると、外から element.shadowRoot で中身を覗けます。テストのときに便利なので、特別な理由がなければ open で十分です。
slotとtemplateで「中身を差し込む」
ここまでの部品は中身が固定でした。でも実際は「枠は共通、中身は呼び出し側で差し替えたい」場面が多い。カード、ダイアログ、ボタンのラベル。そこで使うのが slot です。
slot は、Shadow DOM の中に開けた「ここに外側のHTMLを流し込んでください」という穴です。<slot></slot> と書いておくと、タグの開きと閉じの間に書いたHTMLがそこに表示されます。写真立ての枠(部品)と写真(中身)が分離するイメージですね。
最小のカード部品で見てみます。
class InfoCard extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host { display: block; border: 1px solid #e2e8f0; border-radius: 12px; padding: 1rem; }
h3 { margin: 0 0 .5rem; font-size: 1.1rem; }
::slotted(p) { margin: 0; color: #475569; } /* 差し込まれた p だけにスタイル */
</style>
<h3><slot name="title">タイトル未設定</slot></h3>
<slot>本文がありません</slot>
`;
}
}
customElements.define("info-card", InfoCard);
<info-card>
<span slot="title">請求書の発行</span>
<p>毎月末に自動で発行されます。手動操作は不要です。</p>
</info-card>
ポイントが2つあります。<slot name="title"> のように名前付きslotにすると、slot="title" を付けた要素だけがそこに入ります。複数の差し込み口を作り分けられるわけです。そして slot の中に書いた タイトル未設定 は、外から何も渡されなかったときのデフォルト表示になります。
差し込まれた要素にスタイルを当てたいときは ::slotted() を使います。ただし ::slotted(p) のように直下の要素にしか効かない制約があるので、ここは素直に従うのが無難です。
ちなみに <template> タグは、解析はされるけど描画されないHTMLの雛形です。innerHTML で毎回文字列を組み立てる代わりに、雛形を cloneNode で複製すると、要素を大量に並べるときに速くなります。最初は innerHTML で十分なので、必要になったら思い出すくらいでいいです。
Litを使うと、こんなに短くなる
ここまで素のJSで書いてきて、正直「記述が長いな」と思いませんでしたか。render() の中で文字列を組み立てて、addEventListener を手で貼って…。僕も最初はこれで挫折しかけました。
そこで実務では Lit を使います。Google が出している、Web Components を書くための薄いライブラリです(執筆時点でLit 3系)。仮想DOMを持つReactとは別物で、あくまでWeb Componentsを書きやすくするだけ。出来上がるのは標準のCustom Elementsなので、どこでも動きます。
同じステッパーをLitで書くと、こうなります。
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("lit-stepper")
export class LitStepper extends LitElement {
// reactive property。値が変わると勝手に再描画される
@property({ type: Number }) value = 0;
@property({ type: Number }) step = 1;
@property() label = "Quantity";
// CSSはここに書くだけで Shadow DOM 内に閉じる
static styles = css`
.control { display: inline-flex; gap: .5rem; align-items: center; }
button {
min-width: 2.5rem; min-height: 2.5rem;
border: 1px solid var(--accent, #2563eb);
background: transparent; color: var(--accent, #2563eb);
border-radius: 8px; cursor: pointer;
}
output { font-weight: 700; min-width: 2rem; text-align: center; }
`;
#update(direction: number) {
this.value += this.step * direction;
this.dispatchEvent(
new CustomEvent("quantity-change", {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
render() {
return html`
<div class="control" role="group" aria-label=${this.label}>
<button @click=${() => this.#update(-1)} aria-label="Decrease">-</button>
<output aria-live="polite">${this.value}</output>
<button @click=${() => this.#update(1)} aria-label="Increase">+</button>
</div>
`;
}
}
違いが分かりますか。observedAttributes も attributeChangedCallback も innerHTML 手組みも消えました。@property を付けた値が変われば自動で再描画されるし、@click=${...} でイベントも宣言的に書ける。属性とプロパティの同期も Lit が面倒を見てくれます。
素のJSで一度作ったのは無駄じゃありません。裏で何が起きているかを知っているから、Litが何を省略してくれているのか分かる。仕組みを1回手で書いてから、実務はLitに任せる。この順番をおすすめします。
ReactやVueと共存させる
Web Components はブラウザ標準なので、React や Vue の中でもそのまま <quantity-stepper> と書けます。置き換えではなく共存です。ただし、イベントの受け取り方に一手間あります。
Reactは(バージョンによっては)カスタムイベントを onQuantityChange のようなpropsで素直に受け取れません。確実なのは ref で要素をつかんで addEventListener する方法です。
import { useEffect, useRef, useState } from "react";
import "./quantity-stepper";
export function QuantityField() {
const [quantity, setQuantity] = useState(2);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onChange = (e: Event) =>
setQuantity((e as CustomEvent<{ value: number }>).detail.value);
el.addEventListener("quantity-change", onChange);
return () => el.removeEventListener("quantity-change", onChange); // 後始末を忘れずに
}, []);
return (
<div>
{/* @ts-expect-error カスタムタグはJSXに型がないので一旦無視 */}
<quantity-stepper ref={ref} value="2" step="1" label="Seats" />
<p>Selected: {quantity}</p>
</div>
);
}
Vue 3 なら @quantity-change でそのまま受けられますが、コンパイラに「これは自作タグだよ」と教える必要があります。vite.config.ts にこう足します。
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// ハイフン入りのタグをCustom Elementとして扱わせる
isCustomElement: (tag) => tag.includes("-"),
},
},
}),
],
});
React側の設計を詰めたいならClaude CodeでReact開発を実戦投入する方法も合わせて読むと、refとイベントの扱いがつながります。UI全体を部品化していく文脈はデザインシステムを小さく育てる記事が参考になります。
僕がやらかした失敗3つ
正直に書きます。最初に作ったWeb Componentsは、地雷を全部踏みました。
ひとつ目は、Shadow DOMを使わずに作ったこと。「カプセル化、なんか難しそう」と避けたら、CMSに埋め込んだ瞬間に既存サイトの input スタイルを丸ごと食らって、入力欄が崩壊しました。隔離は面倒の元じゃなくて、面倒を防ぐ盾です。最初から attachShadow しておけばよかった。
ふたつ目は、イベント名を change にしたこと。ネイティブのフォームイベントと名前がかぶって、利用側で「どっちの change だこれ」と大混乱。quantity-change のようにドメイン固有の名前にしてからは、誰も迷わなくなりました。イベント名は立派な公開APIです。
みっつ目は、composed: true を付け忘れたこと。Shadow DOM の中で発火したイベントは、これがないと壁の外まで届きません。「イベント、飛んでないんだけど?」と1時間悩んで、composed: true の1行で解決しました。Shadow DOM を越えさせたいなら必須、と刻みました。
動作を最低限テストする
部品は配ったら終わりじゃなくて、配った先で壊れていないかが大事です。Web Components は標準なので、Vitest と happy-dom だけで契約を確認できます。見た目のスクショより、属性・クリック・イベントの3点を押さえるのが効きます。
import { beforeEach, describe, expect, it } from "vitest";
import "./quantity-stepper";
describe("quantity-stepper", () => {
beforeEach(() => { document.body.innerHTML = ""; });
it("クリックで step ぶん増えて、イベントが外へ飛ぶ", () => {
const el = document.createElement("quantity-stepper") as HTMLElement & { value: number };
el.setAttribute("value", "3");
el.setAttribute("step", "2");
document.body.append(el);
const events: CustomEvent<{ value: number }>[] = [];
el.addEventListener("quantity-change", (e) => events.push(e as CustomEvent<{ value: number }>));
// Shadow DOM の中のボタンを取り出して押す
el.shadowRoot
?.querySelector<HTMLButtonElement>('[data-action="increment"]')
?.click();
expect(el.getAttribute("value")).toBe("5"); // 属性に反映されたか
expect(el.value).toBe(5); // プロパティも一致するか
expect(events).toHaveLength(1); // イベントが1回飛んだか
expect(events[0].detail.value).toBe(5); // detail の形が壊れてないか
expect(events[0].composed).toBe(true); // 壁を越える設定か
});
it("value属性を変えたら表示も変わる", () => {
const el = document.createElement("quantity-stepper");
document.body.append(el);
el.setAttribute("value", "9");
expect(el.shadowRoot?.querySelector("output")?.textContent).toBe("9");
});
});
キーボード操作や実ブラウザでのフォーカス表示まで見たいなら Playwright を足します。アクセシビリティの詰め方はClaude Codeでアクセシビリティ対応を実装するワークフローに寄せると、aria-label や aria-live の判断がぶれません。仕様の一次情報はMDN Web Componentsを基準にしてください。
よくある質問
Q. Web Components はReactの置き換えですか? いいえ、住み分けです。ページ全体の状態管理やルーティングはReact/Vueの仕事。Web Componentsが得意なのは、複数の環境に同じ形で配りたい小さなUI部品です。両方を一緒に使えます。
Q. Shadow DOMは必ず使わないとダメ? 必須ではありません。ただ、外部サイトに埋め込む部品なら強く推奨します。CSSの衝突を根本から防げるからです。使わない場合は、既存サイトのスタイルに巻き込まれる前提で設計してください。
Q. 素のJSとLit、どっちで書くべき? 学習は素のJSで一度、実務はLitで、がおすすめです。仕組みを手で書くと裏側が分かり、Litが何を省略してくれているか腹落ちします。チームで量産するならLitの記述量の少なさが効きます。
Q. タグ名のルールはありますか?
必ずハイフンを1つ以上含めます。my-button はOK、button2 や mybutton はNG。これはブラウザが標準タグと自作タグを区別するための仕様です。
Q. SSRで使うときの注意は?
customElements.define が走るまで、その要素は「ただの未定義タグ」です。AstroやNext.jsでは初期HTMLは出てもイベントはまだ動きません。重要なボタンに使うなら、読み込み前の表示や無効状態を先に決めておきます。
実際に試した結果
冒頭の「角丸が2つ並んだ事件」以来、僕は再利用したいUIをまず Web Components で切り出すようになりました。quantity-stepper を1つ作って、React管理画面、Vue画面、CMSの3か所に同じタグを置いてみたら、デザイン変更が1ファイルで全部に反映される。あの「2回書く徒労」が消えただけで、気持ちがかなり軽くなりました。
つまずいたのは毎回 Shadow DOM 周り(カプセル化とイベントの壁越え)でしたが、composed: true と「変数とpartだけ公開」の2つを守ったら安定しました。フレームワークの流行はこれからも変わります。でもブラウザ標準は簡単には消えない。だから僕は、長く使う部品ほど標準に寄せておく、という方針に落ち着きました。
チームで標準部品を整えたい、CMS埋め込みやレビュー基準まで作りたい、という相談はClaude Code研修・相談で、実際のリポジトリを見ながら一緒に組み立てるのが一番早いです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。