Emotion で「ボタンの設計システム」を構築:サイズ/色/状態/アイコンを型安全に
React アプリケーションで UI コンポーネントを開発する際、デザインの一貫性を保つことは非常に重要です。特にボタンコンポーネントは、アプリケーション全体で頻繁に使用されるため、デザインシステムとして整備することで、開発効率と品質が大きく向上するでしょう。
本記事では、CSS-in-JS ライブラリの Emotion を活用して、型安全なボタン設計システムを構築する方法を詳しく解説します。サイズ、色、状態、アイコンなどのバリエーションを TypeScript の型で厳密に管理し、保守性の高いコンポーネントを実装していきますね。
背景
デザインシステムとボタンコンポーネント
デザインシステムは、UI コンポーネントやデザインパターンを体系的に整理したものです。 特にボタンは、ユーザーとのインタラクションの起点となる重要な要素で、統一されたデザインルールが求められます。
従来の CSS や CSS Modules では、スタイルと TypeScript の型定義が分離しているため、不整合が発生しやすい課題がありました。
以下の図は、デザインシステムにおけるボタンのバリエーション管理の全体像を示しています。
mermaidflowchart TB
design["デザインシステム"]
design --> size["サイズバリエーション<br/>(small, medium, large)"]
design --> color["カラーバリエーション<br/>(primary, secondary, danger)"]
design --> state["状態管理<br/>(default, hover, disabled)"]
design --> icon["アイコン配置<br/>(left, right, none)"]
size --> button["ボタンコンポーネント"]
color --> button
state --> button
icon --> button
button --> output["型安全な UI 出力"]
図の要点:
- デザインシステムが 4 つの主要な軸(サイズ、カラー、状態、アイコン)でボタンを定義
- これらすべてを型安全に統合することで、一貫性のある UI を実現
- TypeScript と Emotion の組み合わせにより、開発時に型エラーで不正な値を検出可能
Emotion の特徴
Emotion は、JavaScript 内で CSS を記述できる CSS-in-JS ライブラリです。 以下のような特徴があり、モダンな React 開発で広く採用されていますね。
| # | 特徴 | 説明 |
|---|---|---|
| 1 | 動的スタイリング | Props に応じてスタイルを動的に変更可能 |
| 2 | TypeScript サポート | 型定義が充実しており、型安全な開発が実現 |
| 3 | パフォーマンス | 実行時に最適化された CSS を生成 |
| 4 | コロケーション | スタイルとコンポーネントを同じファイルで管理可能 |
| 5 | テーマ機能 | ThemeProvider によるグローバルなテーマ管理 |
課題
従来のスタイリング手法の問題点
CSS Modules や Sass を使用した従来の手法では、以下のような課題が顕在化します。
型安全性の欠如により、誤ったクラス名を指定してもコンパイル時に検出できず、実行時にスタイルが適用されない問題が発生しやすいでしょう。 また、スタイルとロジックの分離によって、Props の変更時にスタイルファイルも同時に修正する必要があり、保守性が低下します。
さらに、デザイントークンの管理が困難で、色やサイズなどの値が複数のファイルに散在し、変更時の影響範囲が把握しづらくなりますね。
以下の図は、従来手法における型安全性の問題を示しています。
mermaidflowchart LR
comp["Reactコンポーネント"] -->|クラス名文字列| css["CSS/Sassファイル"]
css -->|適用| dom["DOM要素"]
comp -.->|typo発生| wrong["誤ったクラス名"]
wrong -.->|検出不可| runtime["実行時エラー<br/>スタイル未適用"]
style wrong fill:#ffcccc
style runtime fill:#ffcccc
図の要点:
- クラス名は文字列で渡されるため、TypeScript の型チェックが効かない
- タイポや削除されたクラス名の参照も、ビルド時に検出できない
- 実行時に初めてスタイル適用の失敗に気づくリスクが高い
ボタンコンポーネントに求められる要件
ボタン設計システムを構築するには、以下の要件を満たす必要があります。
| # | 要件 | 詳細 |
|---|---|---|
| 1 | サイズバリエーション | small、medium、large などのサイズを定義 |
| 2 | カラーバリエーション | primary、secondary、danger などの色を定義 |
| 3 | 状態管理 | hover、active、disabled などの状態を表現 |
| 4 | アイコン対応 | 左右へのアイコン配置をサポート |
| 5 | 型安全性 | 不正な Props の組み合わせをコンパイル時に検出 |
| 6 | 拡張性 | 新しいバリエーションを容易に追加可能 |
これらの要件をすべて満たすためには、TypeScript の型システムと Emotion の動的スタイリング機能を組み合わせることが効果的でしょう。
解決策
Emotion を活用した型安全なボタンシステム
Emotion の styled API と TypeScript を組み合わせることで、型安全なボタンコンポーネントを実現できます。
デザイントークンを TypeScript の型として定義し、Props の型制約によって不正な値の入力を防ぎます。 また、テーマオブジェクトを活用することで、色やサイズなどの値を一元管理し、変更時の影響を最小限に抑えられますね。
以下の図は、Emotion を用いた型安全なボタンシステムのアーキテクチャを示しています。
mermaidflowchart TB
theme["テーマ定義<br/>(colors, sizes, spacing)"]
types["TypeScript型定義<br/>(ButtonSize, ButtonVariant)"]
theme --> styled["Emotion styled API"]
types --> styled
styled --> props["Props型チェック"]
props --> comp["Buttonコンポーネント"]
comp --> valid{"型検証"}
valid -->|OK| render["正常レンダリング"]
valid -->|NG| error["コンパイルエラー<br/>不正な値を検出"]
style error fill:#ffcccc
style render fill:#ccffcc
図の要点:
- テーマと型定義を事前に定義し、styled API に統合
- Props は TypeScript により厳密に型チェックされる
- 不正な値はコンパイル時にエラーとして検出され、実行時エラーを防止
- 開発者体験(DX)と品質の両方が向上
実装アプローチ
実装は以下のステップで進めます。
- テーマの定義 - 色、サイズ、間隔などのデザイントークンを定義
- 型定義 - ボタンの Props に使用する型を作成
- スタイルの実装 - Emotion の styled でスタイルを記述
- コンポーネントの実装 - Props を受け取り、適切なスタイルを適用
- バリエーションの追加 - サイズ、色、アイコンなどのバリエーションを実装
それでは、具体的なコードを見ていきましょう。
具体例
プロジェクトのセットアップ
まず、必要なパッケージをインストールします。 Emotion と TypeScript の型定義をプロジェクトに追加しましょう。
bashyarn add @emotion/react @emotion/styled
yarn add -D @types/react @types/node
次に、TypeScript の設定ファイル tsconfig.json で、Emotion の JSX pragma を有効にします。
json{
"compilerOptions": {
"jsxImportSource": "@emotion/react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
テーマの定義
デザイントークンをテーマオブジェクトとして定義します。 色、サイズ、間隔などの値を一箇所に集約することで、変更時の影響を最小限に抑えられますね。
以下は、テーマオブジェクトの定義です。
typescript// theme.ts
export const theme = {
colors: {
primary: '#007bff',
primaryHover: '#0056b3',
primaryActive: '#004085',
secondary: '#6c757d',
secondaryHover: '#5a6268',
secondaryActive: '#545b62',
danger: '#dc3545',
dangerHover: '#c82333',
dangerActive: '#bd2130',
white: '#ffffff',
disabled: '#e9ecef',
disabledText: '#6c757d',
},
sizes: {
small: {
height: '32px',
padding: '0 12px',
fontSize: '14px',
},
medium: {
height: '40px',
padding: '0 16px',
fontSize: '16px',
},
large: {
height: '48px',
padding: '0 24px',
fontSize: '18px',
},
},
borderRadius: '4px',
transition: 'all 0.2s ease-in-out',
} as const;
ポイント:
as constを使用することで、オブジェクトの値を literal type として扱える- 後続の型定義で、テーマの値を型レベルで参照可能になる
- 色は状態ごと(default、hover、active)に定義し、状態管理を容易にする
次に、テーマの型を定義します。
typescript// theme.ts(続き)
export type Theme = typeof theme;
export type ButtonSize = keyof typeof theme.sizes;
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'danger';
ButtonSize と ButtonVariant の型は、テーマから自動的に導出されるため、テーマに新しいサイズや色を追加すると、自動的に型にも反映されますね。
ボタンコンポーネントの型定義
ボタンコンポーネントが受け取る Props の型を定義します。 アイコンの位置、サイズ、カラーバリエーション、disabled 状態などを型として表現しましょう。
typescript// Button.types.ts
import { ReactNode } from 'react';
import { ButtonSize, ButtonVariant } from './theme';
export interface ButtonProps {
// ボタンのサイズ(small, medium, large)
size?: ButtonSize;
// ボタンの色バリエーション
variant?: ButtonVariant;
// ボタンのラベルテキスト
children: ReactNode;
// 左側に配置するアイコン
leftIcon?: ReactNode;
// 右側に配置するアイコン
rightIcon?: ReactNode;
// 無効化状態
disabled?: boolean;
// クリックイベントハンドラ
onClick?: () => void;
// type属性(button, submit, reset)
type?: 'button' | 'submit' | 'reset';
// 幅を100%にするかどうか
fullWidth?: boolean;
}
ポイント:
sizeとvariantは、テーマから導出した型を使用- アイコンは
ReactNode型で受け取り、任意のコンポーネントを配置可能 - オプショナルな Props にはデフォルト値を設定する前提
スタイルの実装
Emotion の styled API を使って、ボタンのスタイルを実装します。
Props に応じて動的にスタイルを変更できるのが、Emotion の強力な機能ですね。
まず、ベースとなるボタンスタイルを定義します。
typescript// Button.styles.ts
import styled from '@emotion/styled';
import { ButtonProps } from './Button.types';
import { theme } from './theme';
// ボタンのベーススタイル
export const StyledButton = styled.button<ButtonProps>`
/* 基本スタイル */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: ${theme.borderRadius};
cursor: pointer;
font-weight: 600;
transition: ${theme.transition};
outline: none;
font-family: inherit;
/* 幅の制御 */
width: ${({ fullWidth }) =>
fullWidth ? '100%' : 'auto'};
/* フォーカス時のスタイル */
&:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
`;
次に、サイズに応じたスタイルを適用する関数を作成します。
typescript// Button.styles.ts(続き)
import { css } from '@emotion/react';
// サイズバリエーションのスタイル
export const getSizeStyles = (
size: ButtonProps['size'] = 'medium'
) => {
const sizeConfig = theme.sizes[size];
return css`
height: ${sizeConfig.height};
padding: ${sizeConfig.padding};
font-size: ${sizeConfig.fontSize};
`;
};
ポイント:
cssヘルパーを使用することで、スタイルを関数として定義可能- デフォルト引数により、Props が未指定でも安全に動作
- テーマから値を取得するため、変更が容易
続いて、カラーバリエーションのスタイルを実装します。
typescript// Button.styles.ts(続き)
// カラーバリエーションのスタイル
export const getVariantStyles = (
variant: ButtonProps['variant'] = 'primary',
disabled?: boolean
) => {
if (disabled) {
return css`
background-color: ${theme.colors.disabled};
color: ${theme.colors.disabledText};
cursor: not-allowed;
&:hover {
background-color: ${theme.colors.disabled};
}
`;
}
const colorMap = {
primary: {
default: theme.colors.primary,
hover: theme.colors.primaryHover,
active: theme.colors.primaryActive,
},
secondary: {
default: theme.colors.secondary,
hover: theme.colors.secondaryHover,
active: theme.colors.secondaryActive,
},
danger: {
default: theme.colors.danger,
hover: theme.colors.dangerHover,
active: theme.colors.dangerActive,
},
};
const colors = colorMap[variant];
return css`
background-color: ${colors.default};
color: ${theme.colors.white};
&:hover {
background-color: ${colors.hover};
}
&:active {
background-color: ${colors.active};
}
`;
};
ポイント:
- disabled 状態では、すべてのバリエーションで同じスタイルを適用
colorMapオブジェクトで各バリエーションの色を定義し、保守性を向上- hover、active 状態のスタイルも同時に定義し、インタラクションを表現
これらのスタイル関数を、StyledButton に適用します。
typescript// Button.styles.ts(続き)
export const StyledButton = styled.button<ButtonProps>`
/* 基本スタイル */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: ${theme.borderRadius};
cursor: pointer;
font-weight: 600;
transition: ${theme.transition};
outline: none;
font-family: inherit;
/* 幅の制御 */
width: ${({ fullWidth }) =>
fullWidth ? '100%' : 'auto'};
/* サイズスタイルの適用 */
${({ size }) => getSizeStyles(size)}
/* カラーバリエーションスタイルの適用 */
${({ variant, disabled }) =>
getVariantStyles(variant, disabled)}
/* フォーカス時のスタイル */
&:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
`;
ボタンコンポーネントの実装
スタイルが定義できたので、ボタンコンポーネント本体を実装します。 Props を受け取り、アイコンの配置やラベルのレンダリングを行いましょう。
typescript// Button.tsx
import React from 'react';
import { ButtonProps } from './Button.types';
import { StyledButton } from './Button.styles';
export const Button: React.FC<ButtonProps> = ({
size = 'medium',
variant = 'primary',
children,
leftIcon,
rightIcon,
disabled = false,
onClick,
type = 'button',
fullWidth = false,
}) => {
return (
<StyledButton
size={size}
variant={variant}
disabled={disabled}
onClick={onClick}
type={type}
fullWidth={fullWidth}
>
{/* 左側アイコン */}
{leftIcon && (
<span className='button-icon-left'>{leftIcon}</span>
)}
{/* ボタンラベル */}
<span className='button-label'>{children}</span>
{/* 右側アイコン */}
{rightIcon && (
<span className='button-icon-right'>
{rightIcon}
</span>
)}
</StyledButton>
);
};
ポイント:
- デフォルト値を設定することで、最小限の Props で使用可能
- アイコンは条件付きレンダリングで、存在する場合のみ表示
- クラス名を付与することで、必要に応じて追加のスタイリングが可能
使用例
実装したボタンコンポーネントを実際に使用してみましょう。 様々なバリエーションを組み合わせることで、柔軟な UI を構築できますね。
まず、基本的な使用例です。
typescript// App.tsx
import React from 'react';
import { Button } from './components/Button';
export const App: React.FC = () => {
return (
<div>
{/* サイズバリエーション */}
<Button size='small'>Small Button</Button>
<Button size='medium'>Medium Button</Button>
<Button size='large'>Large Button</Button>
</div>
);
};
次に、カラーバリエーションの例です。
typescript// App.tsx(続き)
export const ColorVariants: React.FC = () => {
return (
<div>
{/* カラーバリエーション */}
<Button variant='primary'>Primary</Button>
<Button variant='secondary'>Secondary</Button>
<Button variant='danger'>Danger</Button>
</div>
);
};
アイコンを含む例も見てみましょう。 React Icons などのアイコンライブラリと組み合わせると便利です。
typescript// App.tsx(続き)
import { FiSave, FiTrash2, FiPlus } from 'react-icons/fi';
export const IconButtons: React.FC = () => {
return (
<div>
{/* 左アイコン */}
<Button leftIcon={<FiSave />}>保存</Button>
{/* 右アイコン */}
<Button rightIcon={<FiPlus />}>追加</Button>
{/* 両側にアイコン */}
<Button
variant='danger'
leftIcon={<FiTrash2 />}
rightIcon={<FiTrash2 />}
>
削除
</Button>
</div>
);
};
ポイント:
- アイコンは
ReactNodeとして渡せるため、任意のコンポーネントを配置可能 - アイコンとテキストの間隔は、
gapプロパティで自動的に調整される - バリエーションとアイコンを組み合わせることで、視覚的な表現力が向上
disabled 状態とフルワイドの例です。
typescript// App.tsx(続き)
export const StateButtons: React.FC = () => {
const handleClick = () => {
console.log('Button clicked!');
};
return (
<div>
{/* 無効化状態 */}
<Button disabled>Disabled Button</Button>
{/* フルワイド */}
<Button fullWidth onClick={handleClick}>
Full Width Button
</Button>
{/* フォーム送信用 */}
<Button type='submit' variant='primary'>
送信
</Button>
</div>
);
};
型安全性の確認
TypeScript の型システムにより、不正な Props の組み合わせはコンパイル時にエラーとして検出されます。 以下は、型エラーの例ですね。
typescript// エラー例
// ❌ 存在しないサイズ
<Button size="extra-large">Button</Button>
// Error: Type '"extra-large"' is not assignable to type 'ButtonSize'
// ❌ 存在しないバリエーション
<Button variant="success">Button</Button>
// Error: Type '"success"' is not assignable to type 'ButtonVariant'
// ✅ 正しい使用法
<Button size="large" variant="primary">Button</Button>
ポイント:
- タイポや存在しない値は、IDE 上でリアルタイムに検出される
- オートコンプリートにより、利用可能な値が提案される
- コードレビュー時に型エラーを自動検出でき、品質が向上
以下の図は、型安全性による開発フローの改善を示しています。
mermaidsequenceDiagram
participant dev as 開発者
participant ide as IDE/Editor
participant ts as TypeScript
participant build as ビルド
dev->>ide: Propsを入力
ide->>ts: 型チェック実行
alt 型エラーあり
ts->>ide: エラー表示
ide->>dev: 赤線とエラーメッセージ
dev->>ide: 修正
else 型エラーなし
ts->>ide: OK
ide->>dev: オートコンプリート提示
end
dev->>build: ビルド実行
build->>dev: 成功(型安全保証)
図の要点:
- 開発中にリアルタイムで型チェックが行われる
- エラーは即座に IDE 上で可視化され、迅速な修正が可能
- ビルド時には型安全性が保証され、実行時エラーのリスクが低減
拡張例:新しいバリエーションの追加
設計システムの優れた点は、新しいバリエーションを容易に追加できることです。
例えば、success というカラーバリエーションを追加してみましょう。
まず、テーマに色を追加します。
typescript// theme.ts(拡張)
export const theme = {
colors: {
// 既存の色...
success: '#28a745',
successHover: '#218838',
successActive: '#1e7e34',
},
// 残りは同じ...
} as const;
次に、型定義を更新します。
typescript// theme.ts(拡張)
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'danger'
| 'success';
最後に、スタイル関数に色を追加します。
typescript// Button.styles.ts(拡張)
export const getVariantStyles = (
variant: ButtonProps['variant'] = 'primary',
disabled?: boolean
) => {
// disabled処理は同じ...
const colorMap = {
// 既存の色...
success: {
default: theme.colors.success,
hover: theme.colors.successHover,
active: theme.colors.successActive,
},
};
// 残りは同じ...
};
これで、新しいバリエーションが使用可能になりました。
typescript<Button variant='success'>Success Button</Button>
ポイント:
- 3 箇所の修正のみで新しいバリエーションを追加可能
- TypeScript の型システムにより、追加漏れがあればエラーで検出される
- 既存のコードに影響を与えず、安全に拡張できる
テストの実装例
ボタンコンポーネントの品質を保証するために、テストを実装しましょう。 React Testing Library を使用した例です。
まず、テストライブラリをインストールします。
bashyarn add -D @testing-library/react @testing-library/jest-dom vitest
次に、基本的なテストケースを作成します。
typescript// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
import { describe, it, expect, vi } from 'vitest';
describe('Button', () => {
it('ラベルが正しく表示される', () => {
render(<Button>Click Me</Button>);
expect(
screen.getByText('Click Me')
).toBeInTheDocument();
});
it('クリックイベントが正しく発火する', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await userEvent.click(screen.getByText('Click Me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disabled状態ではクリックイベントが発火しない', async () => {
const handleClick = vi.fn();
render(
<Button disabled onClick={handleClick}>
Click Me
</Button>
);
await userEvent.click(screen.getByText('Click Me'));
expect(handleClick).not.toHaveBeenCalled();
});
});
ポイント:
- ユーザーの操作をシミュレートすることで、実際の使用シーンをテスト
- disabled 状態の動作を確認し、アクセシビリティを保証
- モックを活用してイベントハンドラの呼び出しを検証
スナップショットテストも有効です。
typescript// Button.test.tsx(続き)
it('各バリエーションのスナップショットが一致する', () => {
const { container: primary } = render(
<Button variant='primary'>Primary</Button>
);
expect(primary).toMatchSnapshot();
const { container: secondary } = render(
<Button variant='secondary'>Secondary</Button>
);
expect(secondary).toMatchSnapshot();
const { container: danger } = render(
<Button variant='danger'>Danger</Button>
);
expect(danger).toMatchSnapshot();
});
まとめ
本記事では、Emotion を活用して型安全なボタン設計システムを構築する方法を解説しました。
TypeScript の型システムと Emotion の動的スタイリングを組み合わせることで、不正な Props の組み合わせをコンパイル時に検出でき、開発体験と品質の両方が大きく向上しますね。 デザイントークンをテーマとして一元管理することで、デザインの変更にも柔軟に対応可能です。
また、サイズ、カラー、状態、アイコンなどのバリエーションを型として定義することで、IDE のオートコンプリート機能が有効に働き、開発効率も改善されるでしょう。
今回の実装パターンは、ボタン以外のコンポーネント(Input、Select、Card など)にも応用できます。 デザインシステムを構築することで、アプリケーション全体の一貫性が保たれ、保守性の高いコードベースを実現できますね。
ぜひ、あなたのプロジェクトでも Emotion を活用した型安全なコンポーネント設計に挑戦してみてください。
関連リンク
articleEmotion で「ボタンの設計システム」を構築:サイズ/色/状態/アイコンを型安全に
articleEmotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング
articleEmotion チートシート:css/styled/Global/Theme の即戦力スニペット 20
articleEmotion 初期設定完全ガイド:Babel/SWC/型定義/型拡張のベストプラクティス
articleMotion(旧 Framer Motion) vs CSS Transition/WAAPI:可読性・制御性・性能を実測比較
articleEmotion vs styled-components vs Stitches 徹底比較:DX/SSR/パフォーマンス実測
articleGemini CLI のコスト監視ダッシュボード:呼び出し数・トークン・失敗率の可視化
articleGrok アカウント作成から初回設定まで:5 分で完了するスターターガイド
articleFFmpeg コーデック地図 2025:H.264/H.265/AV1/VP9/ProRes/DNxHR の使いどころ
articleESLint の内部構造を覗く:Parser・Scope・Rule・Fixer の連携を図解
articlegpt-oss の量子化別ベンチ比較:INT8/FP16/FP8 の速度・品質トレードオフ
articleDify で実現する RAG 以外の戦略:ツール実行・関数呼び出し・自律エージェントの全体像
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来