T-CREATOR

Web Components で社内デザインシステム基盤を作る:複数フレームワーク横断のコア層

Web Components で社内デザインシステム基盤を作る:複数フレームワーク横断のコア層

社内で複数のフロントエンドフレームワークを運用していると、「React でも Vue でも同じボタンデザインを使いたい」という要望が出てきますよね。 しかし、フレームワークごとにコンポーネントを作り直すのは、保守性やデザインの一貫性に課題があります。

こうした悩みを解決する手段として、Web Components を活用した社内デザインシステム基盤の構築が注目されています。 本記事では、フレームワークに依存しない再利用可能なコンポーネント層を、Web Components でどう実現するかを具体的に解説していきます。

背景

複数フレームワーク環境の現実

現代の企業開発では、プロジェクトごとに異なるフレームワークを採用するケースが増えています。 React で構築された管理画面、Vue.js で開発された顧客向けサイト、Angular を使ったダッシュボードなど、技術スタックが混在することは珍しくありません。

こうした環境で共通のデザインシステムを維持するには、各フレームワーク向けに個別実装が必要となり、工数とメンテナンスコストが増大してしまいます。

Web Components の登場

Web Components は、ブラウザ標準の技術として登場したフレームワーク非依存のコンポーネント仕様です。 主に以下の 3 つの技術で構成されています。

#技術概要
1Custom Elements独自の HTML タグを定義できる仕様
2Shadow DOMカプセル化された DOM・スタイルを提供
3HTML Templates再利用可能な HTML マークアップを定義

これらの技術により、React や Vue といった特定のフレームワークに依存せず、どこでも動作するコンポーネントを作成できるのです。

以下の図は、Web Components が各フレームワークで共通利用される構造を示しています。

mermaidflowchart TB
  wc["Web Components<br/>コア層"]
  react["React アプリ"]
  vue["Vue.js アプリ"]
  angular["Angular アプリ"]
  vanilla["バニラ JS"]

  wc -->|利用| react
  wc -->|利用| vue
  wc -->|利用| angular
  wc -->|利用| vanilla

  style wc fill:#4A90E2,color:#fff

この図が示すように、Web Components を中心に据えることで、複数のフレームワーク環境で統一されたコンポーネントを提供できます。

課題

フレームワーク個別実装の問題点

従来のアプローチでは、フレームワークごとに同じ UI コンポーネントを実装する必要がありました。 たとえば、ボタンコンポーネント 1 つをとっても、React 版・Vue 版・Angular 版と 3 つのコードベースを保守しなければなりません。

この方式には以下のような課題があります。

  • 実装コストの増大: 同じ機能を複数回実装する無駄が発生
  • デザイン不整合のリスク: 実装者によって微妙な差異が生まれやすい
  • 修正の手間: バグ修正や機能追加を全フレームワークで対応する必要がある
  • 学習コストの分散: 各フレームワークの記法を習得する必要がある

デザインシステムの保守性

デザインシステムは、一度作れば終わりではありません。 ブランドガイドラインの変更、アクセシビリティ対応の強化、新しいコンポーネントの追加など、継続的なメンテナンスが必要です。

フレームワークごとに実装が分散していると、変更が全体に波及するのに時間がかかり、結果としてデザインシステムが形骸化してしまうリスクがあります。

以下の図は、従来の個別実装と Web Components 基盤の違いを示しています。

mermaidflowchart LR
  subgraph legacy ["従来の個別実装"]
    direction TB
    ds1["デザイン仕様"]
    react1["React実装"]
    vue1["Vue実装"]
    angular1["Angular実装"]
    ds1 --> react1
    ds1 --> vue1
    ds1 --> angular1
  end

  subgraph unified ["Web Components基盤"]
    direction TB
    ds2["デザイン仕様"]
    wc2["Web Components<br/>コア実装"]
    frameworks2["全フレームワーク"]
    ds2 --> wc2
    wc2 --> frameworks2
  end

  legacy -. 変革 .-> unified

統合型では、コア実装を 1 つに集約することで、保守性と一貫性を大幅に向上できます。

解決策

Web Components によるコア層設計

Web Components を活用することで、フレームワーク非依存の共通コンポーネント層を構築できます。 この層を社内デザインシステムの「コア」として位置づけ、各フレームワークから利用する設計が効果的です。

具体的には、以下のような設計アプローチを取ります。

#設計方針詳細
1Custom Elements で独自タグを定義<ds-button> などの独自タグとして提供
2Shadow DOM でスタイルをカプセル化外部スタイルの影響を受けない独立性を確保
3Properties と Attributes で制御フレームワークから簡単に属性・プロパティで制御可能
4イベント駆動の設計Custom Event で親コンポーネントと通信
5TypeScript で型安全性を確保開発者体験の向上と保守性の強化

設計の核心ポイント

Web Components を社内デザインシステムの基盤として活用する際、以下の 3 つが核心となります。

カプセル化: Shadow DOM により、コンポーネント内のスタイルと DOM ツリーが外部から隔離され、予期しないスタイル干渉を防げます。

再利用性: 標準仕様に基づいているため、どのフレームワークでも <ds-button> のように HTML タグとして利用でき、学習コストが低く抑えられます。

メンテナンス性: コア実装が 1 つなので、修正や機能追加が一箇所で済み、全フレームワークに即座に反映されます。

以下の図は、Web Components ベースのデザインシステム全体像を示しています。

mermaidflowchart TB
  subgraph core["コア層(Web Components)"]
    direction LR
    button["ds-button"]
    input["ds-input"]
    card["ds-card"]
    modal["ds-modal"]
  end

  subgraph framework["フレームワーク層"]
    direction LR
    react["React"]
    vue["Vue"]
    angular["Angular"]
  end

  subgraph app["アプリケーション層"]
    direction LR
    admin["管理画面"]
    customer["顧客サイト"]
    dashboard["ダッシュボード"]
  end

  core --> framework
  framework --> app

  style core fill:#4A90E2,color:#fff
  style framework fill:#7ED321,color:#fff
  style app fill:#F5A623,color:#fff

この 3 層構造により、デザインの一貫性を保ちながら、各アプリケーションの独自性も維持できます。

具体例

ボタンコンポーネントの実装

ここでは、実際に Web Components でボタンコンポーネントを実装していきます。 段階的に説明しますので、初めての方でも理解しやすいかと思います。

ステップ 1: 基本構造の定義

まず、Custom Elements API を使って独自のボタン要素を定義します。

typescript// ds-button.ts

// カスタムエレメントのクラスを定義
class DSButton extends HTMLElement {
  constructor() {
    // 親クラスのコンストラクタを呼び出す
    super();

    // Shadow DOM をアタッチ(カプセル化のため)
    this.attachShadow({ mode: 'open' });
  }
}

このコードでは、HTMLElement を継承したカスタムクラスを定義し、attachShadow() で Shadow DOM を有効化しています。

ステップ 2: スタイルとテンプレートの作成

次に、ボタンのスタイルと HTML テンプレートを定義します。

typescript// ds-button.ts(続き)

// ボタンのスタイルを定義
const styles = `
  :host {
    display: inline-block;
  }

  button {
    padding: 12px 24px;
    font-size: 16px;
    font-weight: 600;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.2s ease;
    background-color: #4A90E2;
    color: #ffffff;
  }

  button:hover {
    background-color: #357ABD;
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3);
  }

  button:active {
    transform: translateY(0);
  }

  button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* バリアント対応 */
  :host([variant="secondary"]) button {
    background-color: #7ED321;
  }

  :host([variant="danger"]) button {
    background-color: #D0021B;
  }
`;

:host セレクタは、カスタムエレメント自身を指定する特別な擬似クラスです。 これにより、コンポーネントレベルでスタイルを制御できます。

typescript// ds-button.ts(続き)

// HTMLテンプレートを定義
const template = `
  <style>${styles}</style>
  <button part="button">
    <slot></slot>
  </button>
`;

<slot> タグは、コンポーネント利用時に渡された子要素を挿入する場所を示します。 part 属性により、外部からこのボタン要素にスタイルを適用することも可能です。

ステップ 3: ライフサイクルメソッドの実装

Custom Elements には、ライフサイクルメソッドが用意されています。 これらを活用して、属性の変化に応じた振る舞いを実装します。

typescript// ds-button.ts(続き)

class DSButton extends HTMLElement {
  // 監視する属性を指定
  static get observedAttributes() {
    return ['disabled', 'variant', 'size'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  // DOM に追加されたときに呼ばれる
  connectedCallback() {
    // テンプレートを Shadow DOM に挿入
    if (this.shadowRoot) {
      this.shadowRoot.innerHTML = template;
    }

    // ボタン要素への参照を取得
    this._button = this.shadowRoot?.querySelector('button');

    // クリックイベントを設定
    this._button?.addEventListener(
      'click',
      this._handleClick.bind(this)
    );

    // 初期状態を反映
    this._updateState();
  }

  // DOM から削除されたときに呼ばれる
  disconnectedCallback() {
    // イベントリスナーをクリーンアップ
    this._button?.removeEventListener(
      'click',
      this._handleClick.bind(this)
    );
  }
}

connectedCallback() はコンポーネントが DOM に追加された際に実行され、初期化処理を行う場所として最適です。

ステップ 4: 属性とプロパティの管理

属性の変更を検知して、コンポーネントの状態を更新する仕組みを実装します。

typescript// ds-button.ts(続き)

class DSButton extends HTMLElement {
  // ... 前述のコード ...

  // 属性が変更されたときに呼ばれる
  attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ) {
    if (oldValue !== newValue) {
      this._updateState();
    }
  }

  // 内部状態を更新するメソッド
  private _updateState() {
    if (!this._button) return;

    // disabled 属性の反映
    const disabled = this.hasAttribute('disabled');
    this._button.disabled = disabled;

    // size 属性の反映
    const size = this.getAttribute('size');
    if (size === 'small') {
      this._button.style.padding = '8px 16px';
      this._button.style.fontSize = '14px';
    } else if (size === 'large') {
      this._button.style.padding = '16px 32px';
      this._button.style.fontSize = '18px';
    }
  }

  // disabled プロパティのゲッター・セッター
  get disabled(): boolean {
    return this.hasAttribute('disabled');
  }

  set disabled(value: boolean) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
}

属性とプロパティの両方でアクセス可能にすることで、HTML と JavaScript の両方から柔軟に制御できます。

ステップ 5: イベントの実装

ボタンクリック時に Custom Event を発火させ、親コンポーネントと通信します。

typescript// ds-button.ts(続き)

class DSButton extends HTMLElement {
  // ... 前述のコード ...

  // クリックイベントハンドラ
  private _handleClick(event: Event) {
    // disabled 状態ではイベントを発火しない
    if (this.disabled) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }

    // カスタムイベントを発火
    const customEvent = new CustomEvent('ds-click', {
      bubbles: true, // イベントバブリングを有効化
      composed: true, // Shadow DOM の境界を超えて伝播
      detail: {
        timestamp: Date.now(),
        variant: this.getAttribute('variant') || 'primary',
      },
    });

    this.dispatchEvent(customEvent);
  }
}

composed: true を設定することで、Shadow DOM の境界を越えてイベントが伝播し、通常の DOM イベントと同様に扱えます。

ステップ 6: カスタムエレメントの登録

最後に、定義したクラスをカスタムエレメントとして登録します。

typescript// ds-button.ts(続き)

// カスタムエレメントとして登録
customElements.define('ds-button', DSButton);

// TypeScript の型定義をエクスポート
export { DSButton };

これで、<ds-button> タグがブラウザで認識され、利用可能になります。

以下の図は、Web Components の内部構造とライフサイクルを示しています。

mermaidstateDiagram-v2
  [*] --> Created: constructor()
  Created --> Connected: connectedCallback()
  Connected --> Rendered: テンプレート挿入
  Rendered --> Listening: イベント設定

  Listening --> AttrChanged: 属性変更
  AttrChanged --> Listening: 状態更新

  Listening --> Disconnected: disconnectedCallback()
  Disconnected --> [*]: クリーンアップ

このライフサイクルを理解することで、適切なタイミングで処理を実行できます。

React での利用例

作成した Web Components を React で利用する方法を見ていきましょう。

ステップ 1: Web Components の読み込み

まず、作成した Web Components を React プロジェクトにインポートします。

typescript// App.tsx

import React, { useRef, useEffect } from 'react';
// Web Components をインポート(登録のため)
import './components/ds-button';

インポートするだけで customElements.define() が実行され、カスタムエレメントが登録されます。

ステップ 2: TypeScript 型定義の拡張

TypeScript で Web Components を利用する際、JSX の型定義を拡張する必要があります。

typescript// types/custom-elements.d.ts

declare namespace JSX {
  interface IntrinsicElements {
    'ds-button': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        variant?: 'primary' | 'secondary' | 'danger';
        size?: 'small' | 'medium' | 'large';
        disabled?: boolean;
      },
      HTMLElement
    >;
  }
}

この型定義により、TypeScript の型チェックとエディタの補完が効くようになります。

ステップ 3: React コンポーネントでの利用

実際に React コンポーネントで Web Components を使ってみます。

typescript// App.tsx(続き)

const App: React.FC = () => {
  // Web Components への参照
  const buttonRef = useRef<HTMLElement>(null);

  // カスタムイベントのリスナーを設定
  useEffect(() => {
    const button = buttonRef.current;
    if (!button) return;

    const handleClick = (event: Event) => {
      const customEvent = event as CustomEvent;
      console.log(
        'ボタンがクリックされました:',
        customEvent.detail
      );
    };

    // ds-click イベントをリスン
    button.addEventListener('ds-click', handleClick);

    // クリーンアップ
    return () => {
      button.removeEventListener('ds-click', handleClick);
    };
  }, []);

  return (
    <div className='app'>
      <h1>React で Web Components を利用</h1>

      {/* Web Components をHTMLタグとして利用 */}
      <ds-button ref={buttonRef} variant='primary'>
        プライマリボタン
      </ds-button>

      <ds-button variant='secondary' size='large'>
        セカンダリボタン
      </ds-button>

      <ds-button variant='danger' disabled>
        無効化されたボタン
      </ds-button>
    </div>
  );
};

export default App;

React でも通常の HTML タグと同様に、直感的に Web Components を利用できます。

Vue.js での利用例

次に、同じ Web Components を Vue.js で利用する方法を見ていきます。

ステップ 1: Vue での設定

Vue 3 では、カスタムエレメントを認識させるための設定が必要です。

typescript// main.ts

import { createApp } from 'vue';
import App from './App.vue';
// Web Components をインポート
import './components/ds-button';

const app = createApp(App);

// カスタムエレメントとして認識させる
app.config.compilerOptions.isCustomElement = (tag) => {
  return tag.startsWith('ds-');
};

app.mount('#app');

isCustomElement を設定することで、Vue が ds- プレフィックスのタグをカスタムエレメントとして扱います。

ステップ 2: Vue コンポーネントでの利用

Vue の template 内で Web Components を利用します。

vue<!-- App.vue -->

<template>
  <div class="app">
    <h1>Vue で Web Components を利用</h1>

    <!-- Web Components を利用 -->
    <ds-button variant="primary" @ds-click="handleClick">
      プライマリボタン
    </ds-button>

    <ds-button
      variant="secondary"
      size="large"
      @ds-click="handleClick"
    >
      セカンダリボタン
    </ds-button>

    <ds-button
      variant="danger"
      :disabled="isDisabled"
      @ds-click="handleClick"
    >
      {{ isDisabled ? '無効' : '有効' }}
    </ds-button>

    <button @click="toggleDisabled">
      ボタンの状態を切り替え
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

// リアクティブな状態管理
const isDisabled = ref(false);

// イベントハンドラ
const handleClick = (event: CustomEvent) => {
  console.log('ボタンがクリックされました:', event.detail);
};

// disabled 状態を切り替え
const toggleDisabled = () => {
  isDisabled.value = !isDisabled.value;
};
</script>

<style scoped>
.app {
  padding: 20px;
}

ds-button {
  margin: 8px;
}
</style>

Vue でも、カスタムイベントを @ds-click のように記述でき、リアクティブなプロパティバインディングも機能します。

パッケージ化と配布

社内デザインシステムとして運用するため、Web Components をパッケージ化して配布する方法を解説します。

ステップ 1: プロジェクト構成

パッケージ化に適したプロジェクト構造を整えます。

bash# プロジェクトのディレクトリ構成
design-system/
├── src/
│   ├── components/
│   │   ├── ds-button/
│   │   │   ├── ds-button.ts
│   │   │   └── index.ts
│   │   ├── ds-input/
│   │   └── ds-card/
│   ├── index.ts
│   └── types/
│       └── index.d.ts
├── dist/
├── package.json
├── tsconfig.json
└── rollup.config.js

ステップ 2: package.json の設定

npm パッケージとして公開するための設定を記述します。

json{
  "name": "@company/design-system",
  "version": "1.0.0",
  "description": "社内デザインシステム - Web Components",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/types/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w",
    "type-check": "tsc --noEmit"
  },
  "devDependencies": {
    "@rollup/plugin-typescript": "^11.1.0",
    "rollup": "^3.20.0",
    "typescript": "^5.0.0"
  },
  "keywords": [
    "web-components",
    "design-system",
    "custom-elements"
  ]
}

mainmodule を両方指定することで、CommonJS と ES Modules の両方に対応できます。

ステップ 3: ビルド設定

Rollup を使ってバンドル設定を行います。

javascript// rollup.config.js

import typescript from '@rollup/plugin-typescript';

export default {
  // エントリーポイント
  input: 'src/index.ts',

  // 出力設定(複数フォーマット対応)
  output: [
    {
      file: 'dist/index.js',
      format: 'cjs', // CommonJS
      sourcemap: true,
    },
    {
      file: 'dist/index.esm.js',
      format: 'esm', // ES Modules
      sourcemap: true,
    },
  ],

  // プラグイン設定
  plugins: [
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist/types',
    }),
  ],
};

この設定により、TypeScript で書いたコードがバンドルされ、型定義ファイルも自動生成されます。

ステップ 4: 社内 npm レジストリへの公開

ビルドしたパッケージを社内 npm レジストリに公開します。

bash# パッケージをビルド
yarn build

# 社内レジストリを指定して公開
yarn publish --registry https://npm.company.internal

公開後、各プロジェクトで以下のようにインストールできます。

bash# プロジェクトでインストール
yarn add @company/design-system --registry https://npm.company.internal

以下の図は、パッケージ化から配布、利用までの全体フローを示しています。

mermaidflowchart LR
  subgraph dev ["開発フロー"]
    direction TB
    code["コンポーネント<br/>実装"]
    build["ビルド"]
    publish["npm publish"]
    code --> build
    build --> publish
  end

  subgraph dist ["配布"]
    direction TB
    registry["社内npm<br/>レジストリ"]
  end

  subgraph useFlow ["利用フロー"]
    direction TB
    install["yarn add"]
    importMod["import"]
    useApp["アプリで利用"]
    install --> importMod
    importMod --> useApp
  end

  dev --> registry
  registry --> useFlow

  style dev fill:#4A90E2,color:#fff
  style dist fill:#7ED321,color:#fff
  style useFlow fill:#F5A623,color:#fff

この一連の流れにより、全社で統一されたデザインシステムを効率的に展開できます。

テストとドキュメント

Web Components の品質を保つため、テストとドキュメント整備も重要です。

単体テストの実装例

Web Components のテストには、Jest と Testing Library を組み合わせて使います。

typescript// ds-button.test.ts

import { screen, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import './ds-button';

describe('ds-button', () => {
  // 各テスト前にDOMをクリア
  beforeEach(() => {
    document.body.innerHTML = '';
  });

  test('レンダリングされること', () => {
    // ボタンをDOMに追加
    document.body.innerHTML =
      '<ds-button>テストボタン</ds-button>';

    // Shadow DOM 内のボタンを取得
    const dsButton = document.querySelector('ds-button');
    const button =
      dsButton?.shadowRoot?.querySelector('button');

    expect(button).toBeInTheDocument();
    expect(button?.textContent).toBe('テストボタン');
  });

  test('クリックイベントが発火すること', async () => {
    document.body.innerHTML =
      '<ds-button>クリック</ds-button>';

    const dsButton = document.querySelector('ds-button');
    const eventHandler = jest.fn();

    // カスタムイベントをリスン
    dsButton?.addEventListener('ds-click', eventHandler);

    // ボタンをクリック
    const button =
      dsButton?.shadowRoot?.querySelector('button');
    await userEvent.click(button!);

    // イベントが発火したことを確認
    expect(eventHandler).toHaveBeenCalledTimes(1);
  });

  test('disabled 属性が機能すること', () => {
    document.body.innerHTML =
      '<ds-button disabled>無効</ds-button>';

    const dsButton = document.querySelector(
      'ds-button'
    ) as any;
    const button =
      dsButton?.shadowRoot?.querySelector('button');

    // ボタンが無効化されていることを確認
    expect(button?.disabled).toBe(true);
    expect(dsButton.disabled).toBe(true);
  });
});

Shadow DOM を考慮したテストコードにより、コンポーネントの品質を担保できます。

まとめ

本記事では、Web Components を活用した社内デザインシステム基盤の構築方法について解説しました。 ポイントを振り返りましょう。

Web Components の優位性: ブラウザ標準技術であり、React・Vue・Angular など、どのフレームワークでも利用可能な汎用性を持っています。

実装のコツ: Custom Elements、Shadow DOM、スロットなどの仕組みを理解し、適切にライフサイクルメソッドを活用することで、堅牢なコンポーネントを構築できます。

フレームワーク統合: 各フレームワークの設定を適切に行うことで、Web Components をネイティブな要素のように扱えます。

パッケージ化の重要性: npm パッケージとして配布することで、社内全体でデザインシステムを共有し、一元管理が実現できます。

Web Components によるデザインシステム基盤は、フレームワークの変遷にも強く、長期的な保守性に優れています。 初期構築には多少の学習コストがかかりますが、複数プロジェクトで一貫した UI を提供し続けられる価値は非常に大きいでしょう。

まずは小さなコンポーネント(ボタン、インプット)から始めて、段階的にデザインシステムを育てていく approach が現実的です。 ぜひ、貴社のプロジェクトでも Web Components ベースのデザインシステム構築にチャレンジしてみてください。

関連リンク