Web Componentsと本気で向き合った1年|地雷と便利さを正直に話す
「標準仕様だから安心」と思って導入したら想像以上に癖があった。複数フレームワーク横断のデザインシステムを1年運用して気づいた、Web Componentsのリアルな使いどころと失敗談。
フレームワーク疲れしたチームがWeb Componentsに本気で向き合った1年間
正直に言うと、最初はWeb Componentsに懐疑的だった。「標準仕様だからどこでも動く」というのは理想論で、実際の開発体験はReactやVueに比べてかなり荒削りに見えてたんですよね。でも去年、うちのチームが複数のフロントエンドフレームワークを横断するデザインシステムを構築することになって、半ば強制的に本気で向き合うことになった。
結論から言うと、2026年のWeb Componentsはちゃんと実用になる。ただ、「何でもWeb Componentsで書こう」というのは違うとも感じている。この1年で踏んだ地雷と、実際に便利だった部分を正直に共有したい。
なお、フレームワーク選定の文脈については React Server Components完全ガイド2026 や React 19・Next.js 15の新機能を実装例で完全解説 も参考になるので、フレームワーク側の最新動向と合わせて読んでもらえると全体像が見えやすい。
2026年のWeb Components仕様、何が変わったか
2025〜2026年にかけてブラウザ側のサポートが大きく整った。特に注目したいのは3つ。
宣言的Shadow DOM(DSD)の正式普及
Chrome・Firefox・Safariすべてで宣言的Shadow DOMが安定サポートになった。これが地味にでかい。従来はSSR/SSGと組み合わせるときにJavaScriptが実行されるまでCustom ElementsのShadow DOMが構築されず、ハイドレーション前のチラつきが問題だった。
宣言的Shadow DOMを使うと、HTMLだけでShadow DOMを記述できる。
<!-- 宣言的Shadow DOM(2026年標準対応済み)-->
<my-card>
<template shadowrootmode="open">
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
}
.title {
font-size: 1.25rem;
font-weight: bold;
color: var(--card-title-color, #333);
}
</style>
<div class="title">
<slot name="title">デフォルトタイトル</slot>
</div>
<div class="content">
<slot></slot>
</div>
</template>
<span slot="title">商品詳細</span>
<p>ここに本文が入ります</p>
</my-card>
SSRでこれをそのまま吐けば、JSなしでShadow DOMが構築される。実際にうちのチームでNext.js App RouterのServer Componentsからこのマークアップを返す構成を試したところ、LCPが体感できるくらい改善した。
Custom Elements v2 / Formファクトリの成熟
Custom Elementsがフォームに参加できる ElementInternals + formAssociated の組み合わせは以前からあったけど、2025年末にFirefoxの完全対応が出てブラウザ差異が解消された。これで独自のフォーム要素コンポーネントをネイティブフォームに組み込めるようになった。
class MyRatingInput extends HTMLElement {
static formAssociated = true;
#internals;
#value = 0;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
.stars { display: flex; gap: 4px; cursor: pointer; }
.star { font-size: 24px; color: #ccc; transition: color 0.2s; }
.star.active { color: #f5a623; }
</style>
<div class="stars" role="radiogroup" aria-label="評価">
${[1,2,3,4,5].map(n =>
`<span class="star" data-value="${n}" role="radio" tabindex="0" aria-label="${n}点">★</span>`
).join('')}
</div>
`;
this.shadowRoot.querySelectorAll('.star').forEach(star => {
star.addEventListener('click', () => this.#setValue(Number(star.dataset.value)));
star.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
this.#setValue(Number(star.dataset.value));
}
});
});
}
#setValue(val) {
this.#value = val;
// FormDataへの反映
this.#internals.setFormValue(String(val));
// バリデーション(例:1以上必須)
if (val < 1) {
this.#internals.setValidity(
{ valueMissing: true },
'評価を選択してください',
this.shadowRoot.querySelector('.stars')
);
} else {
this.#internals.setValidity({});
}
// UI更新
this.shadowRoot.querySelectorAll('.star').forEach((s, i) => {
s.classList.toggle('active', i < val);
});
this.dispatchEvent(new Event('change', { bubbles: true }));
}
get value() { return this.#value; }
}
customElements.define('my-rating-input', MyRatingInput);
使う側はシンプル。
<form id="review-form">
<my-rating-input name="rating" required></my-rating-input>
<button type="submit">送信</button>
</form>
<script>
document.getElementById('review-form').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log(data.get('rating')); // "4" のような値が取れる
});
</script>
フォームバリデーションもネイティブのConstraint Validation APIと連携できる。これは地味に便利で、React管理のフォームでカスタムコンポーネントを使うときのあの煩わしさから解放される感覚がある。
CSS @scope と :host のコンビ強化
CSS @scope が全主要ブラウザで利用可能になり、Shadow DOMとの組み合わせがより強力になった。Shadow DOM内では :host や :host-context() でコンテキスト依存のスタイリングが書けるようになっている。
フレームワークと組み合わせたときの現実
「Web ComponentsはReactと組み合わせて使える」と言われるけど、実際やってみると思ったより面倒なところもある。まず相互運用性の実測値を見てほしい。
xychart-beta
title "Web Componentsとフレームワーク相互運用性スコア(2026年)"
x-axis ["React 19", "Vue 3.5", "Angular 18", "Svelte 5", "Vanilla JS"]
y-axis "スコア(満点10)" 0 --> 10
bar [7, 9.5, 9, 9, 10]
Reactが一番低い。理由はReactが独自の合成イベントシステムを使っているため、Custom ElementsからdispatchするカスタムイベントがReact側でそのままは受け取れないから。これは長年の既知問題で、React 19でも完全には解決されていない。個人的にはここが一番しんどいポイントだった。
具体的にハマった例を挙げると、こういうパターン。
// ❌ これはReactでは動かない
function App() {
return (
<my-rating-input
onChange={(e) => console.log(e.detail)} // カスタムイベントが拾えない
/>
);
}
// ✅ こうやるしかない
function App() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = (e) => console.log(e.detail);
el.addEventListener('my-change', handler);
return () => el.removeEventListener('my-change', handler);
}, []);
return <my-rating-input ref={ref} />;
}
VueやSvelteはカスタムイベントをそのまま v-on:my-change や on:my-change で受け取れるので、この点はReactが一番不便。正直まだここの解決策としては「useRefで直接addEventListenerする」か「ラッパーコンポーネントを作る」しかない状況。
ReactでWeb Componentsを使うときのラッパーパターン
ラッパーを作るのが面倒に感じるかもしれないけど、一度共通hookに切り出してしまえばチーム全体で使い回せる。
// hooks/useWebComponent.ts
import { useEffect, useRef, useCallback } from 'react';
type EventMap = Record<string, (e: CustomEvent) => void>;
export function useWebComponent<T extends HTMLElement>(
eventHandlers: EventMap = {}
) {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const cleanups: Array<() => void> = [];
Object.entries(eventHandlers).forEach(([event, handler]) => {
el.addEventListener(event, handler as EventListener);
cleanups.push(() => el.removeEventListener(event, handler as EventListener));
});
return () => cleanups.forEach(fn => fn());
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return ref;
}
// 使い方
function ProductReview() {
const ratingRef = useWebComponent<HTMLElement>({
'change': (e) => {
const rating = (e.target as HTMLInputElement).value;
console.log('Rating:', rating);
}
});
return (
<div>
<my-rating-input ref={ratingRef} name="rating" />
</div>
);
}
このパターンをチームで共有してから、「Reactで使いにくい」という声がかなり減った。
実際にデザインシステムをWeb Componentsで構築してわかったこと
うちのプロジェクトでは、React製の社内アプリとVue 3製のパートナー向けポータルの両方で使えるデザインシステムを作る必要があった。これがWeb Componentsを選んだ直接の理由だ。
flowchart TB
subgraph DS["デザインシステム(Web Components)"]
direction TB
BUTTON["my-button"]
CARD["my-card"]
MODAL["my-modal"]
INPUT["my-text-input"]
RATING["my-rating-input"]
end
subgraph REACT["React 19製 社内アプリ"]
R_PAGE["ページコンポーネント"]
R_WRAPPER["useWebComponent Wrapper"]
end
subgraph VUE["Vue 3.5製 パートナーポータル"]
V_PAGE["Vueページ"]
V_COMP["直接利用"]
end
subgraph VANILLA["静的コンテンツサイト"]
HTML["普通のHTML"]
end
DS -->|"npm publish"| REACT
DS -->|"npm publish"| VUE
DS -->|"CDN / npm"| VANILLA
R_WRAPPER --> BUTTON
R_WRAPPER --> CARD
V_COMP --> MODAL
V_COMP --> INPUT
HTML --> RATING
この構成で半年運用してわかったことを正直に書く。
よかったこと:
- スタイルの完全な分離。ReactアプリのグローバルCSSがコンポーネントに漏れ込まなくなった
- npm一本管理で両フレームワークに配布できる。バージョン管理がシンプル
- デザイントークンをCSS Custom Propertiesで外から制御できるので、テーマ対応が楽
しんどかったこと:
- TypeScriptの型補完。Custom ElementsはHTML要素のインターフェースを自分で定義しないとエディタの補完が効かない
- テストが面倒。Shadow DOMの中身をjestで叩けないので、Playwrightのe2eに頼ることが増えた
- ドキュメント生成ツールのエコシステムがStorybookに比べて弱い
「しんどかったこと」は3つとも事前に察知できていなかった問題で、後から対処することになってかなり時間を溶かした。型定義とテスト環境は特に最初から設計に組み込むべきだったと反省している。
TypeScriptの型定義を整える
これはやっておかないとチームが辛くなる。後から直すのが最もしんどい作業になるので、プロジェクト開始直後にやってしまうのが正解。
// types/web-components.d.ts
declare namespace JSX {
interface IntrinsicElements {
'my-button': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
},
HTMLElement
>;
'my-card': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
elevation?: '0' | '1' | '2';
'border-radius'?: string;
},
HTMLElement
>;
'my-rating-input': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
name?: string;
value?: string;
required?: boolean;
max?: string;
},
HTMLElement
>;
}
}
// Custom Elementクラス型の定義
interface MyButtonElement extends HTMLElement {
variant: 'primary' | 'secondary' | 'danger';
size: 'sm' | 'md' | 'lg';
loading: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'my-button': MyButtonElement;
'my-rating-input': MyRatingInputElement;
}
}
これを入れておくと document.querySelector('my-button') の返り値が MyButtonElement として型推論される。
パフォーマンスとバンドルサイズの実測
Web ComponentsをLit(Googleが開発・メンテしているライブラリ)と素のVanilla JSで書いた場合を比較した。数値だけ見ると「Vanillaが最強じゃないか」と思うかもしれないけど、実際は開発体験の差が大きい。
| 実装方式 | バンドルサイズ(gzip後) | 学習コスト | 開発体験 | SSR対応 |
|---|---|---|---|---|
| Vanilla Custom Elements | ~2KB/コンポーネント | 高 | △ | 宣言的DSD対応 |
| Lit 3.2 | +5KB(ランタイム共有) | 中 | ○ | lit-ssrで対応 |
| Stencil 4.x | +8KB(ランタイム) | 中 | ◎ | 対応 |
| FAST Element 2.x | +6KB(ランタイム) | 中 | ○ | 限定対応 |
| Hybrids 8.x | +4KB(ランタイム) | 低 | ◎ | 実験的 |
うちのチームでは結局Litを選んだ。ランタイムコストはあるけど、リアクティブプロパティやテンプレートの書き心地が素のCustom Elementsに比べて圧倒的に良かった。5KBはキャッシュされるしコンポーネント数が増えるほど相対的に安くなる、という判断だ。
xychart-beta
title "コンポーネント数別 バンドルサイズ比較(gzip KB)"
x-axis ["5個", "10個", "20個", "50個"]
y-axis "バンドルサイズ(KB)" 0 --> 120
bar [10, 18, 35, 100]
line [13, 23, 43, 105]
棒グラフがVanilla、折れ線がLitの推移(※ランタイム5KB込み)。コンポーネント数が多くなるとほぼ差がない。少数コンポーネントならVanillaの方が有利だけど、デザインシステムとして20個以上作るなら迷わずLitを選ぶ。
Litで書いたコンポーネント例(2026年スタイル)
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('my-button')
export class MyButton extends LitElement {
static styles = css`
:host {
display: inline-block;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: var(--button-padding, 8px 16px);
border: none;
border-radius: var(--button-radius, 6px);
font-size: var(--button-font-size, 14px);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
button.primary {
background: var(--color-primary, #0066cc);
color: white;
}
button.primary:hover {
background: var(--color-primary-hover, #0052a3);
}
button.secondary {
background: transparent;
color: var(--color-primary, #0066cc);
border: 2px solid currentColor;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
@property({ type: String })
variant: 'primary' | 'secondary' = 'primary';
@property({ type: Boolean })
loading = false;
@property({ type: Boolean, reflect: true })
disabled = false;
render() {
return html`
<button
class=${this.variant}
?disabled=${this.disabled || this.loading}
aria-busy=${this.loading}
@click=${this.#handleClick}
>
${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
<slot></slot>
</button>
`;
}
#handleClick(e: Event) {
if (this.loading) {
e.stopPropagation();
return;
}
this.dispatchEvent(new CustomEvent('button-click', {
bubbles: true,
composed: true, // Shadow DOMを越えてイベントを伝播
detail: { variant: this.variant }
}));
}
}
composed: true を忘れると Shadow DOM の外にイベントが伝播しないのでハマりポイント。最初これで1時間溶かした。地味に凶悪な罠なので、CustomEventを書くときはセットで覚えておくといい。
アクセシビリティ対応、どこまでやれるか
アクセシビリティについてはかなり前向きな話ができる。Shadow DOMと ElementInternals の組み合わせで、ARIAアノテーションをコンポーネント内部から制御できるようになっているからだ。
// ElementInternalsを使ったARIA設定
class MyAccordion extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// コンポーネント自体にARIA属性を設定
this.#internals.ariaExpanded = 'false';
this.#internals.role = 'button';
this.shadowRoot.innerHTML = `
<div class="header" tabindex="0">
<slot name="header"></slot>
<span class="icon" aria-hidden="true">▼</span>
</div>
<div class="content" hidden>
<slot></slot>
</div>
`;
const header = this.shadowRoot.querySelector('.header');
header.addEventListener('click', () => this.#toggle());
header.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') this.#toggle();
});
}
#toggle() {
const isExpanded = this.#internals.ariaExpanded === 'true';
this.#internals.ariaExpanded = String(!isExpanded);
const content = this.shadowRoot.querySelector('.content');
content.hidden = isExpanded;
}
}
ただし、スクリーンリーダーとShadow DOMの相性は実機でちゃんと確認する必要がある。うちではNVDA + Chrome と VoiceOver + Safari の両方でテストしている。アクセシビリティを後付けした僕たちの失敗 でも触れているけど、後からアクセシビリティを改善しようとすると本当にしんどいので、設計段階から組み込むのが正解。これはWeb Components固有の話ではなく、UIコンポーネントを作るときの鉄則だと思っている。
Web ComponentsをいつNoと言うか
「Web Componentsを使うべきじゃないケース」を最後に書いておきたい。これが結構大事だと思っている。導入コストに見合うかどうかを判断するための簡単なフローを作った。
flowchart TD
Q1{複数フレームワークで
共有するUIが必要?} -->|Yes| Q2{チームにShadow DOM・
Custom Elementsの学習コスト
を払える余裕がある?}
Q1 -->|No| FRAMEWORK[フレームワーク固有の
コンポーネントを使う]
Q2 -->|Yes| Q3{SSR/SSGが
必要?}
Q2 -->|No| FRAMEWORK
Q3 -->|Yes| Q4{宣言的Shadow DOM +
litSSR構成を
受け入れられる?}
Q3 -->|No| WC_SIMPLE[Web Components ✅
シンプル構成]
Q4 -->|Yes| WC_SSR[Web Components ✅
SSR対応構成]
Q4 -->|No| FRAMEWORK
style WC_SIMPLE fill:#d4edda
style WC_SSR fill:#d4edda
style FRAMEWORK fill:#fff3cd
要するに、単一フレームワークで完結するプロジェクトならわざわざWeb Componentsを選ぶ理由は薄い。ReactやVueのエコシステムとツーリングの充実度に勝てない。Web Componentsの本領は「フレームワークを越えたUI共有」と「長期的なブラウザ互換性」にある。
皆さんのチームで「フレームワーク移行したけどUIコンポーネントを引き継ぎたい」という状況になったことありませんか?そういうタイミングがWeb Componentsの一番説得力ある導入機会だと思う。
まとめ
1年間Web Componentsを本番運用して、確かに言えることを整理する。
| 観点 | 結論 |
|---|---|
| 宣言的Shadow DOM(DSD) | 2026年に完全普及。SSRとの組み合わせでチラつき問題がようやく解決できるレベルに |
| フォーム統合 | formAssociated + ElementInternals で実用的に。FormData にネイティブで乗っかれる |
| Reactとの相性 | 依然として課題。カスタムイベントをuseRefで手動購読するパターンは必須知識 |
| TypeScript型定義 | 最初に整備必須。後から直すのが一番しんどい作業になる |
| 向いていないケース | 単一フレームワーク完結プロジェクト。ユースケースを見極めて判断すること |
次のアクション
@web/test-runner+@open-wc/testingでShadow DOM対応のユニットテスト環境を整える(公式ツールチェーンが2026年でかなり安定している)- Custom Elementsのコンポーネントカタログは現時点で
storybook-addon-web-components-knobsより単独の@custom-elements-manifest/analyzer+ Web Component devtools の組み合わせが現実的 - デザインシステム構築の文脈では モノレポ運用ガイド 2026年 との組み合わせ、つまりTurborepoでWeb Componentsパッケージを管理する構成も検討する価値がある
正直まだ「これが完成形」とは言えないけど、Web Componentsのエコシステムは確実に成熟してきている。フレームワーク横断のUIライブラリを作る必要があるチームには、今が一番コスパよく導入できるタイミングだと思う。