Web Componentsを1年本番運用して気づいた地雷と本音——2026年実体験まとめ
「Web Componentsって今どき使う意義ある?」そんな疑問、うちのチームも持ってました。React・Vue・jQueryが混在する環境で1年運用して見えたShadow DOMの罠とLit 3.xの実際。
先日チームのSlackで「Web Componentsって今どき使う意義ある?」という話が出て、思わず「あるある、でも地雷もある」と長文を返してしまった。うちのチームは約1年前からWeb Componentsを本番に投入していて、正直最初の3ヶ月はかなりしんどかった。でも今は「あって良かった」と思える場面も増えてきた。そんな実体験をまとめておこうと思う。
なお、以前にReact Server ComponentsやNext.js 15 × React 19の本番運用についても書いているので、フレームワーク選定全体を考えているなら合わせて読んでもらえると文脈がつながりやすいと思う。
なぜ今さらWeb Componentsなのか——導入背景
うちのチームが取り組んだのは、複数プロダクト(React製・Vue製・レガシーjQuery製)が混在するデザインシステムの共通コンポーネント化だった。フレームワークに依存しないUIライブラリを作りたいという要件で、Web Componentsが自然な選択肢として浮かんだ。
最初は正直懐疑的だった。2018〜2020年ごろのWeb Componentsの評判は「使いにくい」「Shadow DOMが邪魔」みたいな印象が強くて、「いまさら?」と思っていた。ただ2026年時点では状況がかなり変わっている。
flowchart TD
A[デザインシステム共通化の要件] --> B{フレームワーク選択}
B --> C[React専用コンポーネントライブラリ]
B --> D[Web Components]
B --> E[フレームワーク非依存のVanilla JS]
C --> F[❌ React以外のプロダクトで使えない]
E --> G[❌ 保守コスト大・型安全性低]
D --> H[✅ 全プロダクトで共用可能]
H --> I[Lit 3.x を採用]
I --> J[本番投入 2025年6月]
ブラウザのネイティブサポートが2026年時点では非常に安定していて、Custom Elements・Shadow DOM・HTML Templates・ES Modulesの4つの仕様がすべてのモダンブラウザで使える。Edge Chromiumが本格化して以降、IEという地雷が消えたのが大きかった。
xychart-beta
title "Web Components ブラウザサポート率推移 (%)"
x-axis [2020, 2021, 2022, 2023, 2024, 2025, 2026]
y-axis "サポート率 (%)" 0 --> 100
line [72, 78, 85, 91, 95, 97, 98.5]
Lit 3.xで実装してみた——コードと実感
フレームワークはLit 3.xを選んだ。素のCustom ElementsよりコードがDRYになるし、TypeScriptとの相性が良く、リアクティブプロパティのデコレーター構文が慣れると結構書きやすい。個人的には「ちょうどいい薄さ」のライブラリだと思っている。
実際に作ったシンプルなBadgeコンポーネントを例として出す。
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('ds-badge')
export class DsBadge extends LitElement {
static styles = css`
:host {
display: inline-flex;
align-items: center;
}
.badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
.badge--primary {
background: var(--ds-color-primary, #3b82f6);
color: var(--ds-color-on-primary, #ffffff);
}
.badge--success {
background: var(--ds-color-success, #22c55e);
color: #ffffff;
}
.badge--danger {
background: var(--ds-color-danger, #ef4444);
color: #ffffff;
}
`;
@property({ type: String })
variant: 'primary' | 'success' | 'danger' = 'primary';
@property({ type: String })
label = '';
render() {
return html`
<span class="badge badge--${this.variant}">
<slot>${this.label}</slot>
</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ds-badge': DsBadge;
}
}
CSS Custom Propertiesでテーマを外部から制御できるようにしているのがポイントで、Shadow DOMの中でも :host セレクタを使ったりCSS変数を受け取ったりすることで、デザイントークンとの連携ができる。これは正直地味に便利だった。
各プロダクトでの利用はこんな感じ。
// React プロダクトでの利用(React 19環境)
import '@company/ds-components/badge';
function StatusCell({ status }: { status: string }) {
const variant = status === 'active' ? 'success' : 'danger';
return (
<td>
<ds-badge variant={variant}>{status}</ds-badge>
</td>
);
}
<!-- Vue 3プロダクトでの利用 -->
<template>
<ds-badge :variant="badgeVariant" :label="statusLabel" />
</template>
<script setup lang="ts">
import '@company/ds-components/badge';
// ...
</script>
どちらもimport一発で使える。これがデザインシステムのコンポーネントをフレームワーク横断で共有するうえで最大のメリットだと実感した。
Shadow DOMで本当にハマった3つの落とし穴
ここが一番伝えたいところ。Shadow DOMの「スタイルのカプセル化」は売り文句になっているけど、実際に使うと「なぜ効かない?」「なぜ効きすぎる?」の両極端に悩まされる。
落とし穴1:フォームの<form>とShadow DOM
Shadow DOM内の<input>はネイティブの<form>に参加しない。ElementInternals APIを使えば解決できるが、2025年以前はブラウザサポートが不安定だった。2026年時点では全モダンブラウザで使えるようになったので、以下のように実装する。
@customElement('ds-input')
export class DsInput extends LitElement {
static formAssociated = true; // ← これが重要
private internals: ElementInternals;
constructor() {
super();
this.internals = this.attachInternals();
}
@property({ type: String })
name = '';
@property({ type: String })
value = '';
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.internals.setFormValue(input.value); // ← ネイティブformに値を渡す
this.dispatchEvent(new CustomEvent('ds-change', {
detail: { value: input.value },
bubbles: true,
composed: true // ← Shadow DOMを超えてイベントを伝播させる
}));
}
render() {
return html`
<input
type="text"
.value=${this.value}
@input=${this.handleInput}
/>
`;
}
}
composed: trueを忘れると、カスタムイベントがShadow DOMの境界で止まって何も動かない。これは本番で一度やらかして「なぜかイベントが届かない」で30分溶かした。
落とし穴2:グローバルCSSが当たらない
Tailwind CSSや共通のリセットCSSをShadow DOM内に当てようとするとハマる。仕様通りの動作ではあるんだけど、既存プロジェクトへの追加導入時に混乱しやすい。Lit 3.xではadoptedStyleSheetsを使ってCSSを注入できる。
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
// Tailwind CSSのCSSModuleを読み込む(Vite環境)
import tailwindSheet from '@company/ds-tailwind/base.css?sheet';
@customElement('ds-card')
export class DsCard extends LitElement {
static styles = [
tailwindSheet, // Tailwindのスタイルを注入
// 追加のスコープスタイル...
];
render() {
return html`
<div class="rounded-lg shadow-md p-4 bg-white">
<slot></slot>
</div>
`;
}
}
ただし毎コンポーネントでTailwindを全部読み込むのはバンドルサイズ的にアレなので、共有する基本スタイルシートを1枚作って注入する設計にした。
落とし穴3:SSRとの相性問題
Next.js App RouterでServer Componentから<ds-badge>を使おうとするとエラーになる。Custom ElementsはブラウザAPIに依存しているのでサーバーで実行できないからだ。
// ❌ Server ComponentでWeb Componentsを直接使うのはNG
import '@company/ds-components/badge';
export default function Page() {
return <ds-badge variant="success">OK</ds-badge>;
// → ReferenceError: HTMLElement is not defined
}
// ✅ Client Componentにラップする
'use client';
import '@company/ds-components/badge';
export function Badge({ variant, children }: BadgeProps) {
return <ds-badge variant={variant}>{children}</ds-badge>;
}
この制約はSSRを多用するプロジェクトでは地味に面倒で、「Web Componentsは純粋なCSR環境か、インタラクティブな部分だけで使う」のが現実的な住み分けだと思っている。正直まだここの設計はチームで議論中だ。
2026年のエコシステム——何が使えて何がまだつらいか
1年触ってみて、各ツール・連携先の現状はこんな感じにまとまる。
| 項目 | 状況 | 備考 |
|---|---|---|
| Lit 3.x | ✅ 安定 | TypeScript対応・サイズ約5KB gzip |
| FAST Element 2.x | ✅ 実用可 | Microsoftがメンテ、Fluent UIに採用 |
| Stencil.js | ⚠️ やや停滞 | Ionic製、開発ペースが鈍化 |
| Shoelace 3.0 | ✅ おすすめ | 既製コンポーネント充実、Lit製 |
| React 19との統合 | ✅ 改善済み | カスタムプロパティの型が改善 |
| Vue 3との統合 | ✅ 問題なし | defineCustomElementで双方向も可 |
| Angular統合 | ✅ 公式サポート | CUSTOM_ELEMENTS_SCHEMAが必要 |
| SSR/Hydration | ⚠️ 要注意 | Declarative Shadow DOM(DSD)で一部対応可 |
| フォーム統合 | ✅ 実用的 | ElementInternals普及で2026年以降は安定 |
| テスト(Vitest/WTR) | ✅ 使える | Web Test Runnerが安定 |
| アクセシビリティ | ⚠️ 要実装 | ARIAをきちんと書かないとスクリーンリーダー非対応 |
Shoelaceについては、アクセシビリティを後付けして失敗した話でも少し触れたが、UIコンポーネントのアクセシビリティは最初から設計に組み込まないとあとで本当に痛い目を見る。Web Componentsでも例外ではない。
実際に3プロダクトで共通化を進めた結果、数字にも出てきた。コンポーネントの重複が約60%削減、デザイン不一致の報告が半分以下になったのは地味に嬉しかった。
xychart-beta
title "Web Components 導入前後の開発指標比較"
x-axis ["コンポーネント重複数", "デザイン不一致報告数/月", "新機能実装時間(h)", "バンドルサイズ(KB)"]
y-axis "相対値 (導入前=100)" 0 --> 120
bar [100, 100, 100, 100]
bar [38, 45, 72, 85]
実際の採用判断基準——どんなプロジェクトに向いているか
1年運用してみて、Web Componentsが向いているケースと向いていないケースがはっきりしてきた。
向いているケース
- 複数フレームワーク・複数プロダクト間で共通UIを配布したい
- マイクロフロントエンドでチームごとにフレームワークが違う
- カスタム要素をHTMLネイティブとして扱いたい(CMS埋め込みなど)
- 長期メンテが必要なコンポーネントでフレームワーク依存を避けたい
向いていないケース
- SSRを多用するNext.js App Router構成がメイン
- チーム全員がReactエキスパートで、React以外は使わない確証がある
- 高度なフォーム統合やアニメーションを多用するUI(実装コストが上がる)
- 既存のReactコンポーネントが充実していて、共通化の必要性が低い
判断に迷ったときはこのフローが参考になるかもしれない。
flowchart TD
A[Web Components採用を検討] --> B{複数フレームワーク環境?}
B -->|Yes| C{SSRが主要要件?}
B -->|No| D{長期メンテ・配布が必要?}
C -->|Yes| E[⚠️ DSDで限定的に採用<br/>Client Componentラップ必須]
C -->|No| F[✅ 積極的に採用 Lit 3.x推奨]
D -->|Yes| G[✅ フレームワーク依存排除として有効]
D -->|No| H[❌ フレームワーク専用ライブラリの方がDX高い]
F --> I[Shoelace既製品 or 自作]
G --> I
E --> I
皆さんはどんな基準でWeb Componentsの採用を判断していますか?特に「マイクロフロントエンド」文脈での採用事例があれば聞いてみたい。モノレポ運用との組み合わせでうまくやっているチームがいたら話を聞きたいくらいだ。
Lit 3.xのテスト設計——Web Test Runnerで安定させた
テストは最初Vitestだけでやろうとして詰まった。Custom Elementsはブラウザ環境のAPIに依存しているので、Node.js環境のJSDOMだと挙動が再現できないケースがある。これは結構気づくのに時間がかかって、「なんで通らないんだ」とかなり悩んだ記憶がある。
現在はWeb Test Runner(WTR)とPlaywrightを組み合わせている。
// ds-badge.test.ts(Web Test Runner環境)
import { expect, fixture, html } from '@open-wc/testing';
import '../src/ds-badge.js';
describe('DsBadge', () => {
it('デフォルトのvariantはprimary', async () => {
const el = await fixture<HTMLElement>(html`<ds-badge>テスト</ds-badge>`);
const badge = el.shadowRoot!.querySelector('.badge')!;
expect(badge.classList.contains('badge--primary')).to.be.true;
});
it('successバリアントが正しく適用される', async () => {
const el = await fixture<HTMLElement>(
html`<ds-badge variant="success">OK</ds-badge>`
);
const badge = el.shadowRoot!.querySelector('.badge')!;
expect(badge.classList.contains('badge--success')).to.be.true;
});
it('slotにコンテンツが渡される', async () => {
const el = await fixture<HTMLElement>(
html`<ds-badge>42</ds-badge>`
);
expect(el.textContent?.trim()).to.equal('42');
});
});
@open-wc/testingはWeb Componentsテスト用のユーティリティで、fixture関数でブラウザDOMに要素を挿入して検証できる。これとVitest・Playwright の使い分けで書いたE2Eテストを組み合わせると、テストの信頼性がかなり上がった。
CI設定はこんな感じ。
# .github/workflows/test.yml
name: DS Components Test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: pnpm install --frozen-lockfile
# Unit/Integration: Web Test Runner (Chromium)
- name: Run WTR tests
run: pnpm test:wtr
# E2E: Playwright
- name: Run Playwright E2E
run: pnpm test:e2e
env:
PLAYWRIGHT_BROWSERS_PATH: /usr/bin # キャッシュ活用
まとめ
1年間Web Componentsを本番運用してわかったことをまとめると:
- フレームワーク横断のデザインシステムには現時点で最有力の選択肢。React・Vue・Angular・レガシーJS全部で同じコンポーネントが使えるのは実際に効く
- Shadow DOMの落とし穴は事前に把握しておけば対処できる。フォームの
ElementInternals、イベントのcomposed: true、グローバルCSS非適用の3点を押さえるだけで大半の問題は解決する - SSRが主役の構成には注意が必要。Next.js App Router等ではClient Componentにラップするパターンが現実的。Declarative Shadow DOMも実験的に試しているが、まだ本番投入に確信が持てていない
- Lit 3.xは2026年時点での実用的な選択。約5KB gzipと軽量でTypeScript統合も良好。Shoelaceを参考実装として読むのもおすすめ
- テストはWeb Test Runner + @open-wc/testingを使う。Vitest単体だとDOM APIの差異でハマるケースがある
次のアクションとして、まずは社内の小さなコンポーネント1〜2個をLit 3.xで書いてみるのがいいと思う。いきなり大規模なコンポーネントライブラリを作ろうとすると設計で詰まるので、<ds-badge>や<ds-tooltip>レベルの小さいものから始めて、Shadow DOMやスロットの挙動を手で確かめるのが一番確実だ。