はじめに
この記事は 株式会社TRAILBLAZER Advent Calendar 2025 の記事です。
TRAILBLAZERでフロントエンドエンジニアをしている田原です。
フロントエンドエンジニアからみた文脈においてデザインシステムというとFigmaで作られるスタイルガイドやコンポーネントライブラリが最初に思い浮かぶ方も多いかと思います。
上記で整理されたアウトプットを実際にプロダクトで機能させるのは為にフロントエンド領域から選択できる戦略にはいくつか種類があります。
本記事ではデザインシステムをフロントエンド視点で捉え直した上でオブジェクト指向UI(OOUI)の考え方を踏まえながら具体的な実装戦略をご紹介していきます。
デザインシステムにおけるフロントエンドの責務
フロントエンドが担う領域は、大きく以下の5つに分類できると考えます。
Design Tokens の管理
デザインの変数をコードとして一元管理する考え方(手法)です。
具体的には
- 色(Color)
- 余白や間隔(Spacing)
- タイポグラフィ(font:size/bold)(line-height)
etc
ブランドやプロジェクトに合わせて定義されているものをシステムとして一元管理するイメージです。
管理手法として
JSON/YAML で定義し、CSS Variables や各プラットフォーム向けに変換を行い配布できるようにしておく必要があります。
ツール例:Style Dictionary
Style Dictionaryの使用について大変学びになりました🙇
コンポーネント設計・実装について
- 粒度設計
- Props設計(柔軟性と制約のバランス)
- Composition パターン(Compound Components)
- アクセシビリティの担保
などについて検討していく必要があります。
全てが大切ですが特に粒度設計についてはフロントエンドエンジニアだけでなくデザイナーの方の観点も多分に必要となるのでコミュニケーションを深く取る必要があるかと思います。
また、以下でご紹介もしておりますがオブジェクト指向UIデザインの思想に沿って粒度設計・責務分解をすることで意図が明確化したComponentを分類できると思っております。
↑これは私のReactにおけるCompoundパターンの過去記事の紹介ですw
3. スタイリング戦略の決定
- CSS Modules / CSS-in-JS / Tailwind / Vanilla Extract など
- テーマ切り替え機構(ダーク/ライトモード)
私はTailwindが好きですが(次点でCSS Modules)チームに在籍しているエンジニアの傾向や好みやスキルセットを検討し選定すべきかと思います。
迷ったらCSSライクなものを選んでいくのがキャッチアップ面でも人員採用面でも良いと思います。
スタイリング戦略についてはフロントエンドフレームワークほど意見割れないと思いますがw
4. ドキュメント・カタログの整備
Storybook によるコンポーネントカタログの準備を行い
使用例、Props 一覧、インタラクションの確認などができるようになっていると望ましいとです。
5. 配布・バージョニング
セマンティックバージョニング・破壊的変更の管理などに気をつけながら初期段階では
- シンボリックリンクによるinstall
- pathでの直接install
最終的にはパッケージ化の検討のステップで進めていくのが個人的には良いかと考えております。
参照先のプロジェクトに対して参照元(デザインシステム側)を変更したい際にlocalにおける変更が容易なので
オブジェクト指向UI(OOUI)とデザインシステムの関係
OOUIとは
オブジェクト指向UI(Object-Oriented User Interface)は、ソシオメディア社の上野学氏らが提唱するUI設計の考え方である銀の弾丸になりうるなという点とデザインシステムに入る際に検討しておくべき思想だと思っています。
簡単に従来の「タスク指向」がユーザーに「何をするか」を先に選ばせるのに対し
OOUIは 「何に対して」 を先に選ばせ、その後「何をするか」を選ばせる設計思想です。
以下、内容についての引用🙇
【タスク指向】
動詞 → 名詞
「編集する」→「どのユーザーを?」
【オブジェクト指向】
名詞 → 動詞
「このユーザー」→「編集 / 削除 / 詳細表示」
OOUIの基本原則
- オブジェクトを知覚でき、選択できる
- オブジェクトを選択してからアクションを実行する
- オブジェクトは自身の性質と状態を表示する
デザインシステムへの応用
OOUIの考え方は、コンポーネント設計に直接活かせます。
| OOUI の概念 | コンポーネント設計への応用 |
|---|---|
| オブジェクト | ドメインモデルを反映したコンポーネント(UserCard, ProductTile など) |
| プロパティ | オブジェクトの状態を表す Props(status, variant など) |
| アクション | オブジェクトに対する操作(onEdit, onDelete など) |
| コレクション | オブジェクトの一覧表示(UserList, ProductGrid など) |
つまり、UIコンポーネントはドメインオブジェクトの「表現」であるという視点を持つことで、より一貫性のある設計が可能になります。
OOUIを意識したコンポーネント分類
【オブジェクトコンポーネント】
UserCard:ユーザーオブジェクトの表現
ProductTile:商品オブジェクトの表現
OrderItem:注文オブジェクトの表現
【コレクションコンポーネント】
UserList:ユーザーの一覧
ProductGrid:商品のグリッド表示
【アクションコンポーネント】
ActionMenu:オブジェクトに対する操作メニュー
ContextMenu:コンテキストに応じたアクション
【プリミティブコンポーネント】
Button, Input, Select:オブジェクトに依存しない基本要素
以下の記事も参考にさせて頂きました。学びが多かったです!
コンポーネント配布戦略の比較
デザインシステムのコンポーネントを組織内でどう共有するか。大きく3つのアプローチがあります。
アプローチ1:Design Tokens のみ配布
メリット
- 各チームの技術選定の自由度が高い
- 軽量で導入ハードルが低い
- フレームワークのアップデートに左右されない
デメリット
- 実装のブレが生じやすい(同じTokenでも見た目が微妙に違う)
- 重複作業が発生する
- 内部での実装となるのでアクセシビリティの品質にばらつきが出る可能性が高い
向いているケース
- 各チームの技術スタックが大きく異なる
- コンポーネントの共通化よりブランドの統一が優先
- 小規模で、コンポーネント数が少ない
アプローチ2:ヘッドレスUI + Tokens
メリット
- アクセシビリティ・キーボード操作・フォーカス管理が担保される
- スタイルは各プロジェクトで柔軟にカスタマイズ可能
- 振る舞いのテストは一度で済む
デメリット
- 対応フレームワークにより依存先が絞られる
- ヘッドレスUIライブラリの学習コストがある
- ヘッドレスUI更新に対する追従による負荷
代表的なライブラリ
| ライブラリ | 対応フレームワーク | 特徴 |
|---|---|---|
| Radix UI | React | 最も成熟、shadcn/ui のベース |
| Ark UI | React, Vue, Solid | Chakra チームが開発、マルチFW |
| Headless UI | React, Vue | Tailwind Labs 製、シンプル |
| React Aria | React | Adobe製、最も厳密なa11y対応 |
| Melt UI | Svelte | Svelte 向けヘッドレス |
向いているケース
- 各プロジェクトが単一フレームワークで統一されている
(または近い将来統一予定) - アクセシビリティ担保に比重を置いている
- スタイルのカスタマイズ性を残したい
アプローチ3:Web Components(Lit, Stencil など)
メリット
- フレームワーク非依存で「一度作れば全環境で動く」
- 将来のフレームワーク移行に強い
- Web標準に基づいており、長期的な安定性がある
デメリット
- Shadow DOM によるスタイル分離の制約
- SSR対応が複雑化する場面も
- 各フレームワークの開発体験と比較してDXが劣る場合がある
- フレームワーク固有の機能との統合に工夫が必要
Shadow DOM のスタイル制約と対策について
Web Components の Shadow DOM は外部CSSを遮断するため、カスタマイズ方法を設計時に考慮する必要があります。
制約:外部CSSからの上書きは基本的に不可
/* プロジェクト側のCSS → 効かない */
my-button {
background: red;
}
対策1:CSS Custom Properties(CSS変数は貫通する)
// コンポーネント側
static styles = css`
button {
background: var(--btn-bg, blue);
color: var(--btn-color, white);
}
`;
/* 利用側 */
my-button {
--btn-bg: red;
--btn-color: black;
}
対策2:::part() による部分公開
// コンポーネント側
render() {
return html`<button part="button"><slot></slot></button>`;
}
/* 利用側 */
my-button::part(button) {
background: red;
}
対策3:Props によるバリエーション
@property({ type: String }) variant = 'primary';
@property({ type: String }) size = 'medium';
向いているケース
- 複数のフレームワークが混在している
- 将来的なフレームワーク移行を見据えている
- マイクロフロントエンド構成
- 外部公開するUIライブラリを作りたい
実装例
Design Tokens の定義と変換
tokens.json(Style Dictionary形式)
{
"ds": {
"color": {
"primary": { "value": "#3b82f6", "type": "color" },
"primary-hover": { "value": "#2563eb", "type": "color" },
"secondary": { "value": "#64748b", "type": "color" },
"background": { "value": "#ffffff", "type": "color" },
"overlay": { "value": "rgba(0, 0, 0, 0.5)", "type": "color" }
},
"spacing": {
"xs": { "value": "4px", "type": "dimension" },
"sm": { "value": "8px", "type": "dimension" },
"md": { "value": "16px", "type": "dimension" },
"lg": { "value": "24px", "type": "dimension" }
},
"radius": {
"md": { "value": "6px", "type": "dimension" },
"lg": { "value": "12px", "type": "dimension" }
},
"typography": {
"font-size": {
"sm": { "value": "14px", "type": "dimension" },
"base": { "value": "16px", "type": "dimension" },
"lg": { "value": "18px", "type": "dimension" }
}
}
}
}
出力例:CSS Variables
:root {
--ds-color-primary: #3b82f6;
--ds-color-primary-hover: #2563eb;
--ds-color-secondary: #64748b;
--ds-color-background: #ffffff;
--ds-color-overlay: rgba(0, 0, 0, 0.5);
--ds-spacing-xs: 4px;
--ds-spacing-sm: 8px;
--ds-spacing-md: 16px;
--ds-spacing-lg: 24px;
--ds-radius-md: 6px;
--ds-radius-lg: 12px;
--ds-font-size-sm: 14px;
--ds-font-size-base: 16px;
--ds-font-size-lg: 18px;
}
Tokens の適用方法
Style Dictionary で出力した CSS Variables は、配布戦略に応じて異なる方法で適用します。
グローバルCSS として配布(+ ヘッドレスUI向け)
最もシンプルな方法です。出力された CSS ファイルをアプリケーションのエントリーポイントで読み込みます。
// main.tsx or App.tsx
import '@your-org/design-tokens/tokens.css';
<!-- または HTML で直接 -->
<link rel="stylesheet" href="path/to/tokens.css" />
これにより :root に定義された変数がグローバルに利用可能になり、各コンポーネントから var(--ds-color-primary) のように参照できます。
Lit の static styles に組み込む(Web Components向け)
Web Components は Shadow DOM によりグローバル CSS が遮断されるため、Tokens を各コンポーネントに明示的に取り込む必要があります。
方法1:共通の base styles として定義
// tokens.styles.ts
import { css } from 'lit';
export const tokens = css`
:host {
--ds-color-primary: #3b82f6;
--ds-color-primary-hover: #2563eb;
--ds-color-secondary: #64748b;
--ds-color-overlay: rgba(0, 0, 0, 0.5);
--ds-spacing-xs: 4px;
--ds-spacing-sm: 8px;
--ds-spacing-md: 16px;
--ds-spacing-lg: 24px;
--ds-radius-md: 6px;
--ds-radius-lg: 12px;
--ds-font-size-sm: 14px;
--ds-font-size-base: 16px;
--ds-font-size-lg: 18px;
}
`;
// ds-button.ts
import { tokens } from './tokens.styles';
@customElement('ds-button')
export class DsButton extends LitElement {
static styles = [
tokens,
css`
button {
background: var(--ds-color-primary);
padding: var(--ds-spacing-sm) var(--ds-spacing-md);
}
`
];
}
方法2:CSS Variables の「貫通」を利用
CSS Variables は Shadow DOM を貫通する特性があります。Tokens をグローバルに定義しつつ、コンポーネント側ではフォールバック値を設定しておく方法も有効です。
// コンポーネント側:フォールバック値を持たせる
static styles = css`
button {
background: var(--ds-color-primary, #3b82f6);
}
`;
/* アプリケーション側:グローバルで上書き可能 */
:root {
--ds-color-primary: #8b5cf6; /* ブランドカラーに変更 */
}
この方法では、Tokens が読み込まれていなくてもコンポーネント単体で動作し、グローバルで定義されていれば上書きされます。
ヘッドレスUI + Tokens の実装例(React + Radix UI)
import * as Dialog from '@radix-ui/react-dialog';
import styles from './Modal.module.css';
type ModalProps = {
trigger: React.ReactNode;
title: string;
children: React.ReactNode;
};
export const Modal = ({ trigger, title, children }: ModalProps) => {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.content}>
<Dialog.Title className={styles.title}>{title}</Dialog.Title>
{children}
<Dialog.Close asChild>
<button className={styles.closeButton} aria-label="Close">
×
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
/* Modal.module.css */
/* グローバルに読み込まれた Tokens を参照 */
.overlay {
background: var(--ds-color-overlay);
position: fixed;
inset: 0;
}
.content {
background: var(--ds-color-background);
border-radius: var(--ds-radius-lg);
padding: var(--ds-spacing-lg);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 500px;
width: 90%;
}
.title {
font-size: var(--ds-font-size-lg);
font-weight: 600;
margin-bottom: var(--ds-spacing-md);
}
.closeButton {
position: absolute;
top: var(--ds-spacing-sm);
right: var(--ds-spacing-sm);
background: transparent;
border: none;
font-size: var(--ds-font-size-lg);
cursor: pointer;
}
Web Components の実装例(Lit)
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { tokens } from './tokens.styles';
@customElement('ds-button')
export class DsButton extends LitElement {
@property({ type: String }) variant: 'primary' | 'secondary' = 'primary';
@property({ type: String }) size: 'sm' | 'md' | 'lg' = 'md';
@property({ type: Boolean }) disabled = false;
static styles = [
tokens,
css`
:host {
display: inline-block;
}
button {
font-family: inherit;
border: none;
border-radius: var(--ds-radius-md);
cursor: pointer;
transition: background-color 0.2s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variants */
button.primary {
background: var(--ds-color-primary);
color: white;
}
button.primary:hover:not(:disabled) {
background: var(--ds-color-primary-hover);
}
button.secondary {
background: var(--ds-color-secondary);
color: white;
}
/* Sizes */
button.sm {
padding: var(--ds-spacing-xs) var(--ds-spacing-sm);
font-size: var(--ds-font-size-sm);
}
button.md {
padding: var(--ds-spacing-sm) var(--ds-spacing-md);
font-size: var(--ds-font-size-base);
}
button.lg {
padding: var(--ds-spacing-md) var(--ds-spacing-lg);
font-size: var(--ds-font-size-lg);
}
`
];
render() {
return html`
<button
part="button"
class="${this.variant} ${this.size}"
?disabled="${this.disabled}"
>
<slot></slot>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ds-button': DsButton;
}
}
利用側(どのフレームワークでも)
<!-- Tokens をCSS変数で上書き -->
<style>
ds-button {
--ds-color-primary: #8b5cf6;
--ds-color-primary-hover: #7c3aed;
}
</style>
<ds-button variant="primary" size="lg">送信する</ds-button>
選択のフローチャート
Q1: 組織内のフレームワークは統一されている?
│
├─ YES → Q2: アクセシビリティの品質を確実に担保したい?
│ │
│ ├─ YES → ヘッドレスUI + Tokens
│ │
│ └─ NO → フレームワーク専用コンポーネント + Tokens
│
└─ NO → Q3: 将来的に統一される予定はある?
│
├─ YES(短期) → 統一予定のFW向けヘッドレスUI
│
├─ YES(長期)/NO → Q4: SSRは必須?
│ │
│ ├─ YES → Tokens のみ配布
│ │
│ └─ NO → Web Components
│
└─ 予定なし → Web Components or Tokens のみ
OOUIを踏まえたコンポーネント設計のポイント
1. オブジェクトを中心に据える
// ❌ タスク指向的な命名
<EditUserForm />
<DeleteUserButton />
// ✅ オブジェクト指向的な命名
<UserCard
user={user}
onEdit={handleEdit}
onDelete={handleDelete}
/>
2. オブジェクトの状態を Props で表現
type UserCardProps = {
user: User;
// オブジェクトの状態
status?: 'active' | 'inactive' | 'pending';
isSelected?: boolean;
// オブジェクトへのアクション
onEdit?: () => void;
onDelete?: () => void;
onSelect?: () => void;
};
3. コレクションとオブジェクトを分離
// オブジェクトコンポーネント
<UserCard user={user} />
// コレクションコンポーネント(オブジェクトの配置・選択を管理)
<UserList
users={users}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
renderItem={(user) => <UserCard user={user} />}
/>
4. アクションはオブジェクトに従属
// オブジェクトを選択した後にアクションを表示
<UserCard user={user}>
<ActionMenu>
<ActionMenu.Item onClick={onEdit}>編集</ActionMenu.Item>
<ActionMenu.Item onClick={onDelete} destructive>削除</ActionMenu.Item>
</ActionMenu>
</UserCard>
まとめ
| アプローチ | 導入コスト | 一貫性 | 柔軟性 | FW非依存 |
|---|---|---|---|---|
| Tokens のみ | 低 | △ | ◎ | ◎ |
| ヘッドレスUI + Tokens | 中 | ○ | ○ | △ |
| Web Components | 高 | ◎ | △ | ◎ |
デザインシステムは「作って終わり」ではなく、運用しながら育てていくものであり、形骸化してしまうと分離していることが開発のボトルネックとなることが往々にしてあります。いずれアプローチにしてもDesign Tokenの生成は有効である為、先ずはここからはじめていき組織の状況やプロダクトのフェーズに応じて、最適解を段階的に切り替えていくことが必要かなと思います。
OOUIの考え方を取り入れることで、UIコンポーネントがドメインモデルと自然に対応するようになり、より直感的で一貫性のあるインターフェースを構築できます。この点においてはまだまだ未熟である為、習熟度を上げていかなるドメインに対しても最適なUXが届けれれるように研鑽したいなと思います
最後に
本記事を最後まで読んで頂きありがとうございます![]()
TRAILBLAZERでは一緒に働くメンバーを募集中です!!
皆さまからのご連絡お待ちしております![]()
参考資料


