T-CREATOR

Emotion で「ボタンの設計システム」を構築:サイズ/色/状態/アイコンを型安全に

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 に応じてスタイルを動的に変更可能
2TypeScript サポート型定義が充実しており、型安全な開発が実現
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)と品質の両方が向上

実装アプローチ

実装は以下のステップで進めます。

  1. テーマの定義 - 色、サイズ、間隔などのデザイントークンを定義
  2. 型定義 - ボタンの Props に使用する型を作成
  3. スタイルの実装 - Emotion の styled でスタイルを記述
  4. コンポーネントの実装 - Props を受け取り、適切なスタイルを適用
  5. バリエーションの追加 - サイズ、色、アイコンなどのバリエーションを実装

それでは、具体的なコードを見ていきましょう。

具体例

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。 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';

ButtonSizeButtonVariant の型は、テーマから自動的に導出されるため、テーマに新しいサイズや色を追加すると、自動的に型にも反映されますね。

ボタンコンポーネントの型定義

ボタンコンポーネントが受け取る 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;
}

ポイント

  • sizevariant は、テーマから導出した型を使用
  • アイコンは 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 を活用した型安全なコンポーネント設計に挑戦してみてください。

関連リンク