T-CREATOR

Emotion の css プロップで爆速スタイリング

Emotion の css プロップで爆速スタイリング

React 開発者の皆さん、スタイリングで時間を取られすぎていませんか?

コンポーネント一つ一つに CSS ファイルを作成し、クラス名を考え、ファイルを行き来しながらスタイルを調整する。そんな煩雑な作業から解放されたいと思ったことはありませんか。

今日ご紹介するのは、Emotion のcssプロップを使った革命的なスタイリング手法です。この手法を身につければ、驚くほどスムーズにスタイリングができるようになり、開発効率が格段に向上します。実際に私自身も、この手法を導入してからスタイリングにかかる時間が半分以下になりました。

この記事では、基本的な使い方から実践的な応用例まで、段階的に学習できるように構成しています。最後まで読んでいただければ、きっと「もっと早く知りたかった!」と思っていただけるはずです。

背景

従来のスタイリング手法の課題

Web 開発の歴史を振り返ると、スタイリング手法は常に進化し続けてきました。しかし、従来の手法には多くの課題がありました。

通常の CSS ファイル管理の問題点

typescript// 従来の方法:別々のファイルを管理
// Button.css
.button {
  background-color: #007bff;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
}

.button-primary {
  background-color: #28a745;
}

.button-secondary {
  background-color: #6c757d;
}
typescript// Button.tsx
import React from 'react';
import './Button.css';

const Button = ({ variant = 'primary', children }) => {
  return (
    <button className={`button button-${variant}`}>
      {children}
    </button>
  );
};

この方法では、以下のような問題が発生します:

#問題点具体的な影響
1ファイル管理が複雑コンポーネントと CSS ファイルを別々に管理する必要がある
2グローバルスコープの汚染クラス名が他のコンポーネントと衝突する可能性
3デッドコードの発生使われなくなったスタイルが残りやすい
4動的スタイリングの困難さprops に基づいたスタイル変更が複雑

CSS-in-JS の台頭と Emotion の位置づけ

これらの課題を解決するため、CSS-in-JS という概念が登場しました。JavaScript 内でスタイルを記述することで、コンポーネントとスタイルを密結合させ、より保守性の高いコードを書けるようになります。

CSS-in-JS ライブラリの比較

ライブラリ特徴学習コストパフォーマンス
styled-componentsテンプレートリテラル記法良好
Emotion柔軟な API、軽量優秀
JSS設定が豊富良好

Emotion は、その中でも特に開発者体験(DX)を重視して設計されており、直感的で使いやすい API を提供しています。

課題

外部 CSS ファイルの管理コスト

大規模な React アプリケーションでは、CSS ファイルの管理が複雑になります。

typescript// 実際にプロジェクトで発生する管理コスト
src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.css
│   │   └── Button.test.tsx
│   ├── Card/
│   │   ├── Card.tsx
│   │   ├── Card.css
│   │   └── Card.test.tsx
│   └── Modal/
│       ├── Modal.tsx
│       ├── Modal.css
│       └── Modal.test.tsx
└── styles/
    ├── globals.css
    ├── variables.css
    └── reset.css

このファイル構成では、以下のような問題が発生します:

  1. ファイル数の増加: コンポーネントが増えるたびに CSS ファイルも増える
  2. インポート管理: 各コンポーネントで CSS ファイルのインポートが必要
  3. 依存関係の複雑化: どの CSS ファイルがどのコンポーネントで使われているか分からない

クラス名の衝突問題

グローバルスコープでのクラス名管理は、大きな問題を引き起こします。

css/* Header.css */
.container {
  max-width: 1200px;
  margin: 0 auto;
}

/* Footer.css */
.container {
  max-width: 800px; /* 意図しない上書き */
  margin: 0 auto;
}

このような衝突は、以下の問題を引き起こします:

  • 予期しないスタイルの変更: 他のコンポーネントのスタイルが影響を受ける
  • デバッグの困難さ: どの CSS ファイルが原因かを特定するのが困難
  • メンテナンス性の低下: 安全にスタイルを変更することが困難

動的スタイリングの複雑さ

props や state に基づいてスタイルを動的に変更する場合、従来の方法では非常に複雑になります。

typescript// 従来の方法:複雑な条件分岐
const Button = ({ variant, size, disabled, loading }) => {
  const getClassName = () => {
    let className = 'button';

    if (variant === 'primary')
      className += ' button-primary';
    if (variant === 'secondary')
      className += ' button-secondary';
    if (size === 'large') className += ' button-large';
    if (size === 'small') className += ' button-small';
    if (disabled) className += ' button-disabled';
    if (loading) className += ' button-loading';

    return className;
  };

  return (
    <button className={getClassName()}>
      {loading ? 'Loading...' : children}
    </button>
  );
};

この方法では、以下のような問題があります:

  • コードの可読性低下: 条件分岐が多くなり、理解が困難
  • 型安全性の欠如: TypeScript でもクラス名の型チェックができない
  • リアルタイム更新の困難: 値の変更に応じたスタイル更新が複雑

解決策

css プロップの基本概念と仕組み

Emotion のcssプロップは、これらの課題を根本から解決します。JavaScript のオブジェクト記法やテンプレートリテラルを使って、直接コンポーネントにスタイルを適用できます。

typescript// Emotionの基本的な使い方
import { css } from '@emotion/react';

const Button = ({ children }) => {
  return (
    <button
      css={css`
        background-color: #007bff;
        color: white;
        padding: 10px 20px;
        border: none;
        border-radius: 4px;
        cursor: pointer;

        &:hover {
          background-color: #0056b3;
        }
      `}
    >
      {children}
    </button>
  );
};

この方法の革新的な点は、以下の通りです:

  1. コンポーネントと一体化: スタイルがコンポーネントと同じファイルに記述される
  2. スコープの自動化: 自動的にユニークなクラス名が生成される
  3. JavaScript 連携: props や state を直接スタイルに反映できる

Emotion が提供する開発体験

Emotion は、開発者にとって理想的な開発体験を提供します。

開発効率の向上

typescript// ファイル間の移動が不要
const Card = ({ elevated, children }) => (
  <div
    css={css`
      background: white;
      border-radius: 8px;
      padding: 16px;
      box-shadow: ${elevated
        ? '0 4px 8px rgba(0,0,0,0.1)'
        : 'none'};
      transition: box-shadow 0.2s ease;
    `}
  >
    {children}
  </div>
);

型安全性の確保

typescript// TypeScriptとの完璧な統合
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'small' | 'medium' | 'large';
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({
  variant,
  size,
  children,
}) => {
  const getVariantStyles = () => {
    switch (variant) {
      case 'primary':
        return '#007bff';
      case 'secondary':
        return '#6c757d';
      case 'danger':
        return '#dc3545';
    }
  };

  const getSizeStyles = () => {
    switch (size) {
      case 'small':
        return { padding: '4px 8px', fontSize: '12px' };
      case 'medium':
        return { padding: '8px 16px', fontSize: '14px' };
      case 'large':
        return { padding: '12px 24px', fontSize: '16px' };
    }
  };

  return (
    <button
      css={css`
        background-color: ${getVariantStyles()};
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        ${getSizeStyles()}

        &:hover {
          opacity: 0.9;
        }
      `}
    >
      {children}
    </button>
  );
};

具体例

css プロップの基本的な使い方

まず、Emotion の基本的なセットアップから始めましょう。

bash# Emotionのインストール
yarn add @emotion/react @emotion/styled

# TypeScriptを使用している場合
yarn add -D @emotion/babel-plugin

Next.js プロジェクトでの設定:

typescript// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  compiler: {
    emotion: true,
  },
};

module.exports = nextConfig;

基本的な使い方パターン

typescriptimport { css } from '@emotion/react';

// パターン1: テンプレートリテラル
const Container = () => (
  <div
    css={css`
      max-width: 1200px;
      margin: 0 auto;
      padding: 0 16px;
    `}
  >
    <h1>Welcome to Emotion!</h1>
  </div>
);
typescript// パターン2: オブジェクト記法
const Container = () => (
  <div
    css={{
      maxWidth: 1200,
      margin: '0 auto',
      padding: '0 16px',
      backgroundColor: '#f5f5f5',
    }}
  >
    <h1>Welcome to Emotion!</h1>
  </div>
);

よくあるエラーと解決方法

typescript// ❌ よくあるエラー
TypeError: Cannot read properties of undefined (reading 'css')

// 解決方法: JSXプラグマの追加
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

動的スタイリングの実装

props に基づいた動的スタイリングが、Emotion の真の力を発揮する場面です。

typescript// 動的スタイリングの実装例
interface AlertProps {
  type: 'success' | 'warning' | 'error' | 'info';
  children: React.ReactNode;
  dismissible?: boolean;
}

const Alert: React.FC<AlertProps> = ({
  type,
  children,
  dismissible = false,
}) => {
  const getAlertStyles = () => {
    const baseStyles = {
      padding: '12px 16px',
      borderRadius: '4px',
      marginBottom: '16px',
      border: '1px solid',
      position: 'relative' as const,
    };

    const typeStyles = {
      success: {
        backgroundColor: '#d4edda',
        borderColor: '#c3e6cb',
        color: '#155724',
      },
      warning: {
        backgroundColor: '#fff3cd',
        borderColor: '#ffeaa7',
        color: '#856404',
      },
      error: {
        backgroundColor: '#f8d7da',
        borderColor: '#f5c6cb',
        color: '#721c24',
      },
      info: {
        backgroundColor: '#cce7ff',
        borderColor: '#bee5eb',
        color: '#0c5460',
      },
    };

    return { ...baseStyles, ...typeStyles[type] };
  };

  return (
    <div css={getAlertStyles()}>
      {children}
      {dismissible && (
        <button
          css={css`
            position: absolute;
            right: 8px;
            top: 8px;
            background: none;
            border: none;
            font-size: 16px;
            cursor: pointer;
            opacity: 0.7;

            &:hover {
              opacity: 1;
            }
          `}
        >
          ×
        </button>
      )}
    </div>
  );
};

使用例

typescriptconst App = () => (
  <div>
    <Alert type='success' dismissible>
      データが正常に保存されました!
    </Alert>
    <Alert type='warning'>
      このアクションは取り消せません。
    </Alert>
    <Alert type='error'>
      エラーが発生しました。再試行してください。
    </Alert>
  </div>
);

TypeScript との組み合わせ

TypeScript と Emotion を組み合わせることで、型安全なスタイリングが可能になります。

typescript// テーマの型定義
interface Theme {
  colors: {
    primary: string;
    secondary: string;
    success: string;
    warning: string;
    error: string;
  };
  spacing: {
    xs: number;
    sm: number;
    md: number;
    lg: number;
    xl: number;
  };
  breakpoints: {
    mobile: string;
    tablet: string;
    desktop: string;
  };
}

// テーマオブジェクト
const theme: Theme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    warning: '#ffc107',
    error: '#dc3545',
  },
  spacing: {
    xs: 4,
    sm: 8,
    md: 16,
    lg: 24,
    xl: 32,
  },
  breakpoints: {
    mobile: '@media (max-width: 768px)',
    tablet: '@media (max-width: 1024px)',
    desktop: '@media (min-width: 1025px)',
  },
};
typescript// ThemeProviderの設定
import { ThemeProvider } from '@emotion/react';

const App = () => (
  <ThemeProvider theme={theme}>
    <MainContent />
  </ThemeProvider>
);

// テーマを使用したコンポーネント
const StyledButton = ({
  children,
}: {
  children: React.ReactNode;
}) => (
  <button
    css={(theme: Theme) => css`
      background-color: ${theme.colors.primary};
      padding: ${theme.spacing.sm}px ${theme.spacing.md}px;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;

      &:hover {
        opacity: 0.9;
      }

      ${theme.breakpoints.mobile} {
        padding: ${theme.spacing.xs}px ${theme.spacing.sm}px;
        font-size: 14px;
      }
    `}
  >
    {children}
  </button>
);

型安全性のメリット

typescript// ❌ 存在しないプロパティでエラーが発生
const WrongComponent = () => (
  <div
    css={(theme: Theme) => css`
      color: ${theme.colors
        .nonexistent}; // TypeScriptエラー
    `}
  >
    Content
  </div>
);

// ✅ 正しい使用方法
const CorrectComponent = () => (
  <div
    css={(theme: Theme) => css`
      color: ${theme.colors.primary}; // OK
    `}
  >
    Content
  </div>
);

レスポンシブデザインへの対応

Emotion を使用したレスポンシブデザインの実装は、非常に直感的です。

typescript// レスポンシブデザインの実装例
const ResponsiveCard = ({
  children,
}: {
  children: React.ReactNode;
}) => (
  <div
    css={css`
      background: white;
      border-radius: 8px;
      padding: 24px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

      /* デスクトップファースト */
      @media (max-width: 1024px) {
        padding: 16px;
      }

      @media (max-width: 768px) {
        padding: 12px;
        border-radius: 4px;
      }

      @media (max-width: 480px) {
        padding: 8px;
        border-radius: 0;
        box-shadow: none;
        border-bottom: 1px solid #eee;
      }
    `}
  >
    {children}
  </div>
);

より高度なレスポンシブグリッドシステム

typescript// グリッドシステムの実装
interface GridProps {
  columns?: number;
  gap?: number;
  children: React.ReactNode;
}

const Grid: React.FC<GridProps> = ({
  columns = 3,
  gap = 16,
  children,
}) => (
  <div
    css={css`
      display: grid;
      grid-template-columns: repeat(${columns}, 1fr);
      gap: ${gap}px;

      @media (max-width: 1024px) {
        grid-template-columns: repeat(
          ${Math.max(1, columns - 1)},
          1fr
        );
      }

      @media (max-width: 768px) {
        grid-template-columns: repeat(
          ${Math.max(1, columns - 2)},
          1fr
        );
        gap: ${gap * 0.75}px;
      }

      @media (max-width: 480px) {
        grid-template-columns: 1fr;
        gap: ${gap * 0.5}px;
      }
    `}
  >
    {children}
  </div>
);

テーマ変数の活用

テーマシステムを活用することで、一貫性のあるデザインシステムを構築できます。

typescript// 包括的なテーマシステム
const designSystem = {
  colors: {
    // Primary colors
    primary: {
      50: '#eff6ff',
      100: '#dbeafe',
      500: '#3b82f6',
      600: '#2563eb',
      900: '#1e3a8a',
    },
    // Semantic colors
    success: '#10b981',
    warning: '#f59e0b',
    error: '#ef4444',
    // Neutral colors
    gray: {
      50: '#f9fafb',
      100: '#f3f4f6',
      200: '#e5e7eb',
      300: '#d1d5db',
      400: '#9ca3af',
      500: '#6b7280',
      600: '#4b5563',
      700: '#374151',
      800: '#1f2937',
      900: '#111827',
    },
  },
  typography: {
    fontFamily: {
      sans: ['Inter', 'system-ui', 'sans-serif'],
      mono: ['Fira Code', 'monospace'],
    },
    fontSize: {
      xs: '0.75rem',
      sm: '0.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
      '3xl': '1.875rem',
      '4xl': '2.25rem',
    },
    fontWeight: {
      light: '300',
      normal: '400',
      medium: '500',
      semibold: '600',
      bold: '700',
    },
  },
  spacing: {
    px: '1px',
    0: '0',
    1: '0.25rem',
    2: '0.5rem',
    3: '0.75rem',
    4: '1rem',
    5: '1.25rem',
    6: '1.5rem',
    8: '2rem',
    10: '2.5rem',
    12: '3rem',
    16: '4rem',
    20: '5rem',
    24: '6rem',
  },
  shadows: {
    sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
    base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
    xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
  },
  borderRadius: {
    none: '0',
    sm: '0.125rem',
    base: '0.25rem',
    md: '0.375rem',
    lg: '0.5rem',
    xl: '0.75rem',
    '2xl': '1rem',
    full: '9999px',
  },
};

テーマを活用したコンポーネント

typescript// テーマを活用したボタンコンポーネント
interface ButtonProps {
  variant?:
    | 'primary'
    | 'secondary'
    | 'success'
    | 'warning'
    | 'error';
  size?: 'sm' | 'base' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}

const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'base',
  children,
  onClick,
  disabled = false,
}) => {
  const getVariantStyles = () => {
    switch (variant) {
      case 'primary':
        return {
          backgroundColor: designSystem.colors.primary[500],
          color: 'white',
          '&:hover': !disabled
            ? {
                backgroundColor:
                  designSystem.colors.primary[600],
              }
            : {},
        };
      case 'secondary':
        return {
          backgroundColor: designSystem.colors.gray[100],
          color: designSystem.colors.gray[800],
          '&:hover': !disabled
            ? {
                backgroundColor:
                  designSystem.colors.gray[200],
              }
            : {},
        };
      case 'success':
        return {
          backgroundColor: designSystem.colors.success,
          color: 'white',
          '&:hover': !disabled
            ? {
                backgroundColor: '#059669',
              }
            : {},
        };
      case 'warning':
        return {
          backgroundColor: designSystem.colors.warning,
          color: 'white',
          '&:hover': !disabled
            ? {
                backgroundColor: '#d97706',
              }
            : {},
        };
      case 'error':
        return {
          backgroundColor: designSystem.colors.error,
          color: 'white',
          '&:hover': !disabled
            ? {
                backgroundColor: '#dc2626',
              }
            : {},
        };
      default:
        return {};
    }
  };

  const getSizeStyles = () => {
    switch (size) {
      case 'sm':
        return {
          padding: `${designSystem.spacing[2]} ${designSystem.spacing[3]}`,
          fontSize: designSystem.typography.fontSize.sm,
        };
      case 'base':
        return {
          padding: `${designSystem.spacing[3]} ${designSystem.spacing[4]}`,
          fontSize: designSystem.typography.fontSize.base,
        };
      case 'lg':
        return {
          padding: `${designSystem.spacing[4]} ${designSystem.spacing[6]}`,
          fontSize: designSystem.typography.fontSize.lg,
        };
      default:
        return {};
    }
  };

  return (
    <button
      css={css`
        border: none;
        border-radius: ${designSystem.borderRadius.md};
        cursor: ${disabled ? 'not-allowed' : 'pointer'};
        font-weight: ${designSystem.typography.fontWeight
          .medium};
        transition: all 0.2s ease;
        opacity: ${disabled ? 0.6 : 1};
        box-shadow: ${designSystem.shadows.sm};

        &:focus {
          outline: none;
          box-shadow: ${designSystem.shadows.md};
        }

        ${getVariantStyles()}
        ${getSizeStyles()}
      `}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

使用例とバリエーション

typescriptconst ButtonShowcase = () => (
  <div
    css={css`
      display: flex;
      gap: ${designSystem.spacing[4]};
      flex-wrap: wrap;
      padding: ${designSystem.spacing[8]};
    `}
  >
    <Button variant='primary' size='sm'>
      小さいボタン
    </Button>
    <Button variant='secondary'>標準ボタン</Button>
    <Button variant='success' size='lg'>
      大きいボタン
    </Button>
    <Button variant='warning'>警告ボタン</Button>
    <Button variant='error' disabled>
      無効ボタン
    </Button>
  </div>
);

まとめ

Emotion のcssプロップを使ったスタイリング手法は、現代の React 開発において革命的な変化をもたらします。

得られる主なメリット

項目従来の方法Emotion の css プロップ
開発効率CSS ファイルとの往復が必要コンポーネント内で完結
保守性グローバルスコープでの管理スコープが自動的に分離
型安全性クラス名の型チェック困難TypeScript で完全な型チェック
動的スタイリング複雑な条件分岐が必要JavaScript 式で直接記述
パフォーマンス未使用 CSS の残存リスク必要なスタイルのみ生成

開発者として感じる変化

この手法を導入することで、以下のような変化を実感できるでしょう:

  1. 集中力の向上: ファイル間の移動が不要になり、コンポーネントの実装に集中できます
  2. バグの減少: スコープが自動的に分離されるため、スタイルの衝突が発生しません
  3. メンテナンス性の向上: コンポーネントとスタイルが一体化しているため、変更が容易です
  4. チーム開発の効率化: 一貫したスタイリング手法により、チーム全体の開発効率が向上します

今後の学習ステップ

Emotion を更に活用するために、以下の学習をお勧めします:

  1. styledコンポーネントの活用: より再利用性の高いコンポーネント設計
  2. パフォーマンス最適化: useMemoとの組み合わせによる最適化
  3. アニメーション連携: framer-motionなどとの組み合わせ
  4. テストアプローチ: Emotion スタイルのテスト手法

React 開発者として、効率的で保守性の高いスタイリングを身につけることは、あなたの技術力を大きく向上させるでしょう。今日から始めて、より良い開発体験を手に入れてください。

関連リンク