T-CREATOR

Emotion のテーマ機能を使いこなすデザインパターン集

Emotion のテーマ機能を使いこなすデザインパターン集

フロントエンド開発において、統一感のあるデザインシステムを構築することは、ユーザー体験の向上だけでなく、開発効率の飛躍的な改善にもつながります。特に React アプリケーションでは、コンポーネント間でのスタイル管理が複雑化しがちですが、Emotion のテーマ機能を活用することで、この課題を優雅に解決できるのです。

今回は、Emotion のテーマ機能を使いこなすための実践的なデザインパターンを、初心者の方でも安心して取り組めるよう段階的にご紹介いたします。実際のプロジェクトで遭遇するエラーや解決策も含めて、あなたの開発スキルを次のレベルへと導く内容となっております。

背景

CSS-in-JS でのテーマ管理の重要性

現代の Web アプリケーション開発では、コンポーネント指向の設計が主流となっています。しかし、各コンポーネントが独立してスタイルを持つと、デザインの一貫性を保つことが困難になってしまいます。

CSS-in-JS ライブラリである Emotion は、JavaScript の力を借りてスタイルを管理し、テーマという概念を通じてアプリケーション全体のデザインを統一する仕組みを提供しています。これにより、保守性の高いスタイルシステムを構築できるのです。

styled-components から Emotion への移行トレンド

多くのプロジェクトで styled-components から Emotion への移行が進んでいます。その理由として、以下の特徴が挙げられます:

項目Emotionstyled-components
バンドルサイズ軽量やや重い
パフォーマンス高速標準的
TypeScript 対応優秀良好
学習コスト低いやや高い

デザインシステム構築におけるテーマの役割

デザインシステムにおいて、テーマは「デザインの DNA」とも言える重要な存在です。カラーパレット、タイポグラフィ、スペーシングなどの基本要素を一元管理することで、ブランドの一貫性を保ちながら、効率的な開発が可能になります。

課題

テーマの設計方針が定まらない

多くの開発チームが直面する最初の壁が、「どのようにテーマを設計すべきか」という根本的な問題です。プロジェクトの規模や要件に応じて、最適なテーマ構造は大きく異なるため、明確な指針が必要です。

コンポーネント間でのスタイル統一が困難

個々のコンポーネントでスタイルを定義していると、気づかないうちに微妙な違いが生まれ、全体の統一感が損なわれてしまいます。特に複数人での開発では、この問題が顕著に現れます。

ダークモード対応の複雑さ

現代のアプリケーションでは、ダークモード対応がほぼ必須となっています。しかし、単純にカラーを変更するだけでは不十分で、コンテキストに応じた適切なデザイン調整が求められます。

解決策

Emotion テーマの基本設定

ThemeProvider の導入方法

まずは、Emotion のテーマ機能を使用するための基本的なセットアップから始めましょう。以下のコードは、アプリケーション全体にテーマを適用するための設定です。

typescript// theme.ts - テーマオブジェクトの定義
export const theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#64748b',
    background: '#ffffff',
    text: '#1f2937',
  },
  typography: {
    fontFamily: '"Inter", sans-serif',
    fontSize: {
      small: '0.875rem',
      medium: '1rem',
      large: '1.25rem',
    },
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
  },
} as const;

// テーマの型定義
export type Theme = typeof theme;

次に、React アプリケーションのルートコンポーネントでThemeProviderを設定します。

typescript// App.tsx - ThemeProviderの設定
import { ThemeProvider } from '@emotion/react';
import { theme } from './theme';

function App() {
  return (
    <ThemeProvider theme={theme}>
      <div className='App'>
        {/* あなたのアプリケーションコンポーネント */}
        <Header />
        <Main />
        <Footer />
      </div>
    </ThemeProvider>
  );
}

export default App;

テーマオブジェクトの基本構造

効果的なテーマオブジェクトは、以下の要素で構成されます。この構造を基本として、プロジェクトの要件に合わせてカスタマイズしていきましょう。

typescript// theme/base.ts - 基本テーマ構造
export const baseTheme = {
  // カラーパレット
  colors: {
    // プライマリカラー系
    primary: {
      50: '#eff6ff',
      500: '#3b82f6',
      900: '#1e3a8a',
    },
    // セマンティックカラー
    success: '#10b981',
    warning: '#f59e0b',
    error: '#ef4444',
    // ニュートラルカラー
    gray: {
      50: '#f9fafb',
      500: '#6b7280',
      900: '#111827',
    },
  },

  // タイポグラフィ
  typography: {
    fontFamily: {
      sans: '"Inter", system-ui, sans-serif',
      mono: '"Fira Code", monospace',
    },
    fontWeight: {
      normal: 400,
      medium: 500,
      semibold: 600,
      bold: 700,
    },
  },

  // レイアウト
  layout: {
    container: {
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
    },
    borderRadius: {
      sm: '0.25rem',
      md: '0.375rem',
      lg: '0.5rem',
    },
  },
} as const;

初級デザインパターン

カラーパレット管理パターン

カラーパレットの管理は、テーマ設計の基礎中の基礎です。以下のパターンを使用することで、一貫性のあるカラー体系を構築できます。

typescript// theme/colors.ts - カラーパレット管理
const createColorScale = (baseColor: string) => ({
  50: `${baseColor}0D`, // 5% opacity
  100: `${baseColor}1A`, // 10% opacity
  200: `${baseColor}33`, // 20% opacity
  300: `${baseColor}4D`, // 30% opacity
  400: `${baseColor}66`, // 40% opacity
  500: baseColor, // Base color
  600: `${baseColor}CC`, // 80% opacity
  700: `${baseColor}B3`, // 70% opacity
  800: `${baseColor}99`, // 60% opacity
  900: `${baseColor}80`, // 50% opacity
});

export const colors = {
  brand: createColorScale('#3b82f6'),
  neutral: createColorScale('#64748b'),
  semantic: {
    success: createColorScale('#10b981'),
    warning: createColorScale('#f59e0b'),
    error: createColorScale('#ef4444'),
    info: createColorScale('#06b6d4'),
  },
};

実際のコンポーネントでこのカラーパレットを使用する際は、以下のようになります:

typescript// components/Button.tsx - カラーパレットの活用
import styled from '@emotion/styled';
import { Theme } from '../theme';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'success' | 'error';
}

const Button = styled.button<ButtonProps>`
  padding: ${({ theme }) =>
    `${theme.spacing.sm} ${theme.spacing.md}`};
  border-radius: ${({ theme }) =>
    theme.layout.borderRadius.md};
  font-weight: ${({ theme }) =>
    theme.typography.fontWeight.medium};
  border: none;
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  // バリアント別のスタイリング
  ${({ theme, variant = 'primary' }) => {
    const colorMap = {
      primary: theme.colors.brand,
      secondary: theme.colors.neutral,
      success: theme.colors.semantic.success,
      error: theme.colors.semantic.error,
    };

    const colors = colorMap[variant];

    return `
      background-color: ${colors[500]};
      color: white;
      
      &:hover {
        background-color: ${colors[600]};
      }
      
      &:active {
        background-color: ${colors[700]};
      }
    `;
  }}
`;

export default Button;

タイポグラフィ統一パターン

テキストの一貫性を保つためのタイポグラフィシステムを構築しましょう。以下のパターンでは、フォントサイズと行間の関係性を体系化しています。

typescript// theme/typography.ts - タイポグラフィシステム
export const typography = {
  // フォントスケール(1.25倍比率)
  scale: {
    xs: '0.75rem', // 12px
    sm: '0.875rem', // 14px
    base: '1rem', // 16px
    lg: '1.125rem', // 18px
    xl: '1.25rem', // 20px
    '2xl': '1.5rem', // 24px
    '3xl': '1.875rem', // 30px
    '4xl': '2.25rem', // 36px
  },

  // 行間の設定
  lineHeight: {
    tight: '1.25',
    normal: '1.5',
    relaxed: '1.75',
  },

  // テキストスタイルプリセット
  styles: {
    h1: {
      fontSize: '2.25rem',
      lineHeight: '1.25',
      fontWeight: 700,
      letterSpacing: '-0.025em',
    },
    h2: {
      fontSize: '1.875rem',
      lineHeight: '1.25',
      fontWeight: 600,
      letterSpacing: '-0.025em',
    },
    body: {
      fontSize: '1rem',
      lineHeight: '1.5',
      fontWeight: 400,
    },
    caption: {
      fontSize: '0.875rem',
      lineHeight: '1.25',
      fontWeight: 400,
    },
  },
};

このタイポグラフィシステムを使用したコンポーネントの例:

typescript// components/Text.tsx - 統一されたテキストコンポーネント
import styled from '@emotion/styled';
import { Theme } from '../theme';

interface TextProps {
  variant?: keyof Theme['typography']['styles'];
  color?: string;
}

const Text = styled.p<TextProps>`
  margin: 0;

  ${({ theme, variant = 'body' }) => {
    const style = theme.typography.styles[variant];
    return `
      font-size: ${style.fontSize};
      line-height: ${style.lineHeight};
      font-weight: ${style.fontWeight};
      letter-spacing: ${style.letterSpacing || 'normal'};
    `;
  }}

  color: ${({ theme, color = theme.colors.neutral[900] }) =>
    color};
`;

export default Text;

スペーシング(余白)管理パターン

一貫性のあるレイアウトを実現するために、スペーシングシステムを体系化します。8px ベースの設計手法を採用することで、デザイナーとエンジニアの連携もスムーズになります。

typescript// theme/spacing.ts - 8pxベーススペーシングシステム
export const spacing = {
  // 基本単位: 8px
  0: '0',
  1: '0.25rem', // 4px
  2: '0.5rem', // 8px
  3: '0.75rem', // 12px
  4: '1rem', // 16px
  5: '1.25rem', // 20px
  6: '1.5rem', // 24px
  8: '2rem', // 32px
  10: '2.5rem', // 40px
  12: '3rem', // 48px
  16: '4rem', // 64px
  20: '5rem', // 80px

  // セマンティックスペーシング
  xs: '0.25rem',
  sm: '0.5rem',
  md: '1rem',
  lg: '1.5rem',
  xl: '2rem',
  '2xl': '2.5rem',
  '3xl': '3rem',
};

// スペーシングユーティリティ関数
export const createSpacing = (multiplier: number) =>
  `${multiplier * 0.25}rem`;

中級デザインパターン

レスポンシブ対応テーマパターン

モバイルファーストの開発アプローチに対応したブレークポイント管理システムを構築しましょう。

typescript// theme/responsive.ts - レスポンシブ対応テーマ
export const breakpoints = {
  sm: '640px',
  md: '768px',
  lg: '1024px',
  xl: '1280px',
  '2xl': '1536px',
};

// メディアクエリヘルパー関数
export const media = {
  sm: `@media (min-width: ${breakpoints.sm})`,
  md: `@media (min-width: ${breakpoints.md})`,
  lg: `@media (min-width: ${breakpoints.lg})`,
  xl: `@media (min-width: ${breakpoints.xl})`,
  '2xl': `@media (min-width: ${breakpoints['2xl']})`,
};

// レスポンシブ値の型定義
export type ResponsiveValue<T> =
  | T
  | {
      base?: T;
      sm?: T;
      md?: T;
      lg?: T;
      xl?: T;
      '2xl'?: T;
    };

レスポンシブ対応のコンポーネント実装例:

typescript// components/Container.tsx - レスポンシブコンテナ
import styled from '@emotion/styled';
import { media } from '../theme/responsive';

interface ContainerProps {
  maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
  padding?: boolean;
}

const Container = styled.div<ContainerProps>`
  width: 100%;
  margin: 0 auto;

  ${({ padding = true }) =>
    padding &&
    `
    padding-left: 1rem;
    padding-right: 1rem;
    
    ${media.sm} {
      padding-left: 1.5rem;
      padding-right: 1.5rem;
    }
  `}

  ${({ maxWidth = 'xl' }) => {
    if (maxWidth === 'full') return '';

    const widthMap = {
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px',
    };

    return `max-width: ${widthMap[maxWidth]};`;
  }}
`;

export default Container;

コンポーネントバリアント管理パターン

複数のスタイルバリエーションを持つコンポーネントを効率的に管理するためのパターンです。

typescript// theme/variants.ts - バリアント管理システム
export const createVariants = <
  T extends Record<string, any>
>(
  variants: T
) => variants;

export const buttonVariants = createVariants({
  // サイズバリアント
  size: {
    small: {
      padding: '0.5rem 0.75rem',
      fontSize: '0.875rem',
      lineHeight: '1.25rem',
    },
    medium: {
      padding: '0.625rem 1rem',
      fontSize: '1rem',
      lineHeight: '1.5rem',
    },
    large: {
      padding: '0.75rem 1.25rem',
      fontSize: '1.125rem',
      lineHeight: '1.75rem',
    },
  },

  // カラーバリアント
  color: {
    primary: (theme: Theme) => ({
      backgroundColor: theme.colors.brand[500],
      color: 'white',
      '&:hover': {
        backgroundColor: theme.colors.brand[600],
      },
    }),
    secondary: (theme: Theme) => ({
      backgroundColor: theme.colors.neutral[100],
      color: theme.colors.neutral[900],
      '&:hover': {
        backgroundColor: theme.colors.neutral[200],
      },
    }),
  },
});

アニメーション統一パターン

ユーザー体験を向上させるための一貫したアニメーションシステムを構築します。

typescript// theme/animations.ts - アニメーションシステム
export const animations = {
  // アニメーション時間
  duration: {
    fast: '150ms',
    normal: '250ms',
    slow: '350ms',
  },

  // イージング関数
  easing: {
    linear: 'cubic-bezier(0, 0, 1, 1)',
    ease: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
    easeIn: 'cubic-bezier(0.4, 0, 1, 1)',
    easeOut: 'cubic-bezier(0, 0, 0.2, 1)',
    easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
  },

  // 事前定義されたアニメーション
  presets: {
    fadeIn: {
      from: { opacity: 0 },
      to: { opacity: 1 },
    },
    slideUp: {
      from: {
        opacity: 0,
        transform: 'translateY(10px)',
      },
      to: {
        opacity: 1,
        transform: 'translateY(0)',
      },
    },
  },
};

上級デザインパターン

ダークモード切り替えパターン

現代的なアプリケーションに必須のダークモード対応を実装しましょう。

typescript// theme/darkMode.ts - ダークモード対応
export const lightTheme = {
  colors: {
    background: {
      primary: '#ffffff',
      secondary: '#f8fafc',
      tertiary: '#f1f5f9',
    },
    text: {
      primary: '#0f172a',
      secondary: '#475569',
      tertiary: '#64748b',
    },
    border: '#e2e8f0',
  },
};

export const darkTheme = {
  colors: {
    background: {
      primary: '#0f172a',
      secondary: '#1e293b',
      tertiary: '#334155',
    },
    text: {
      primary: '#f8fafc',
      secondary: '#cbd5e1',
      tertiary: '#94a3b8',
    },
    border: '#475569',
  },
};

// テーマ切り替えコンテキスト
import {
  createContext,
  useContext,
  useState,
  ReactNode,
} from 'react';

interface ThemeContextType {
  isDark: boolean;
  toggleTheme: () => void;
  theme: typeof lightTheme;
}

const ThemeContext = createContext<
  ThemeContextType | undefined
>(undefined);

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error(
      'useTheme must be used within ThemeProvider'
    );
  }
  return context;
};

ダークモード切り替えコンポーネントの実装:

typescript// components/ThemeToggle.tsx - テーマ切り替えボタン
import styled from '@emotion/styled';
import { useTheme } from '../theme/darkMode';

const ToggleButton = styled.button`
  padding: 0.5rem;
  border: none;
  border-radius: 0.375rem;
  background-color: ${({ theme }) =>
    theme.colors.background.secondary};
  color: ${({ theme }) => theme.colors.text.primary};
  cursor: pointer;
  transition: all 0.2s ease-in-out;

  &:hover {
    background-color: ${({ theme }) =>
      theme.colors.background.tertiary};
  }
`;

const ThemeToggle = () => {
  const { isDark, toggleTheme } = useTheme();

  return (
    <ToggleButton onClick={toggleTheme}>
      {isDark ? '☀️' : '🌙'}
    </ToggleButton>
  );
};

export default ThemeToggle;

動的テーマ変更パターン

ユーザーの好みに応じてリアルタイムでテーマを変更できる仕組みを実装します。

typescript// hooks/useCustomTheme.ts - 動的テーマ変更フック
import { useState, useCallback } from 'react';
import { Theme } from '../theme';

interface CustomThemeOptions {
  primaryColor?: string;
  fontSize?: number;
  borderRadius?: number;
}

export const useCustomTheme = (baseTheme: Theme) => {
  const [customizations, setCustomizations] =
    useState<CustomThemeOptions>({});

  const updateTheme = useCallback(
    (options: Partial<CustomThemeOptions>) => {
      setCustomizations((prev) => ({
        ...prev,
        ...options,
      }));
    },
    []
  );

  const customTheme = {
    ...baseTheme,
    colors: {
      ...baseTheme.colors,
      ...(customizations.primaryColor && {
        primary: customizations.primaryColor,
      }),
    },
    typography: {
      ...baseTheme.typography,
      ...(customizations.fontSize && {
        scale: Object.entries(
          baseTheme.typography.scale
        ).reduce(
          (acc, [key, value]) => ({
            ...acc,
            [key]: `${
              parseFloat(value) * customizations.fontSize!
            }rem`,
          }),
          {}
        ),
      }),
    },
  };

  return { customTheme, updateTheme, customizations };
};

マルチブランド対応パターン

複数のブランドやクライアント向けにテーマを管理するための高度なパターンです。

typescript// theme/brands.ts - マルチブランド対応
export const brandThemes = {
  brandA: {
    colors: {
      primary: '#3b82f6',
      secondary: '#64748b',
    },
    typography: {
      fontFamily: '"Inter", sans-serif',
    },
    logo: '/logos/brand-a.svg',
  },

  brandB: {
    colors: {
      primary: '#10b981',
      secondary: '#6b7280',
    },
    typography: {
      fontFamily: '"Roboto", sans-serif',
    },
    logo: '/logos/brand-b.svg',
  },
};

// ブランド検出とテーマ選択
export const detectBrand = (): keyof typeof brandThemes => {
  const hostname = window.location.hostname;

  if (hostname.includes('brand-a')) return 'brandA';
  if (hostname.includes('brand-b')) return 'brandB';

  return 'brandA'; // デフォルト
};

具体例

実際のプロジェクトでの実装コード

ここまでのパターンを組み合わせて、実際のプロジェクトでよく使われるコンポーネントを実装してみましょう。

typescript// components/Card.tsx - 実用的なカードコンポーネント
import styled from '@emotion/styled';
import { Theme } from '../theme';

interface CardProps {
  elevation?: 'low' | 'medium' | 'high';
  padding?: 'small' | 'medium' | 'large';
  borderRadius?: 'small' | 'medium' | 'large';
}

const Card = styled.div<CardProps>`
  background-color: ${({ theme }) =>
    theme.colors.background.primary};
  border: 1px solid ${({ theme }) => theme.colors.border};
  transition: all ${({ theme }) =>
      theme.animations.duration.normal} ${({ theme }) =>
      theme.animations.easing.easeOut};

  // 立体感のレベル
  ${({ elevation = 'medium' }) => {
    const shadowMap = {
      low: '0 1px 3px rgba(0, 0, 0, 0.1)',
      medium: '0 4px 6px rgba(0, 0, 0, 0.1)',
      high: '0 10px 15px rgba(0, 0, 0, 0.1)',
    };
    return `box-shadow: ${shadowMap[elevation]};`;
  }}

  // パディング
  ${({ theme, padding = 'medium' }) => {
    const paddingMap = {
      small: theme.spacing.md,
      medium: theme.spacing.lg,
      large: theme.spacing.xl,
    };
    return `padding: ${paddingMap[padding]};`;
  }}
  
  // 角丸
  ${({ theme, borderRadius = 'medium' }) => {
    const radiusMap = {
      small: theme.layout.borderRadius.sm,
      medium: theme.layout.borderRadius.md,
      large: theme.layout.borderRadius.lg,
    };
    return `border-radius: ${radiusMap[borderRadius]};`;
  }}
  
  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
  }
`;

export default Card;

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

Emotion のテーマ機能を使用する際によく遭遇するエラーと、その解決方法をご紹介します。

エラー 1: Property 'theme' does not exist on type

typescript// ❌ 間違った実装
const Button = styled.button`
  color: ${({ theme }) =>
    theme.colors.primary}; // TypeScriptエラー
`;

// ✅ 正しい実装
import { Theme } from '../theme';

const Button = styled.button<{ theme: Theme }>`
  color: ${({ theme }) => theme.colors.primary};
`;

// または、型定義ファイルを作成
// emotion.d.ts
import '@emotion/react';
import { Theme as CustomTheme } from './theme';

declare module '@emotion/react' {
  export interface Theme extends CustomTheme {}
}

エラー 2: Cannot read property 'colors' of undefined

このエラーは、ThemeProviderでコンポーネントをラップし忘れた場合に発生します。

typescript// ❌ ThemeProviderなしでテーマを使用
function App() {
  return <StyledComponent />; // テーマが undefined
}

// ✅ ThemeProviderでラップ
import { ThemeProvider } from '@emotion/react';
import { theme } from './theme';

function App() {
  return (
    <ThemeProvider theme={theme}>
      <StyledComponent />
    </ThemeProvider>
  );
}

エラー 3: ReferenceError: theme is not defined

typescript// ❌ テーマオブジェクトの循環参照
export const theme = {
  colors: {
    primary: theme.colors.secondary, // エラー: theme is not defined
    secondary: '#64748b',
  },
};

// ✅ 正しい実装
const baseColors = {
  primary: '#3b82f6',
  secondary: '#64748b',
};

export const theme = {
  colors: {
    primary: baseColors.primary,
    secondary: baseColors.secondary,
    primaryHover: baseColors.primary + '80', // 正しい参照方法
  },
};

パフォーマンス最適化のコツ

大規模なアプリケーションでテーマを効率的に使用するためのテクニックをご紹介します。

typescript// theme/optimized.ts - 最適化されたテーマ設計
import { useMemo } from 'react';

// メモ化されたテーマプロバイダー
export const OptimizedThemeProvider = ({
  children,
  isDark,
}: {
  children: React.ReactNode;
  isDark: boolean;
}) => {
  const theme = useMemo(
    () => ({
      ...(isDark ? darkTheme : lightTheme),
      // 重い計算があればここでメモ化
    }),
    [isDark]
  );

  return (
    <ThemeProvider theme={theme}>{children}</ThemeProvider>
  );
};

// CSS変数を活用したパフォーマンス向上
export const cssVariableTheme = {
  toCSS: (theme: Theme) => ({
    '--color-primary': theme.colors.primary,
    '--color-secondary': theme.colors.secondary,
    '--spacing-md': theme.spacing.md,
  }),
};

まとめ

Emotion のテーマ機能は、単なるスタイル管理ツールを超えて、あなたの開発体験を劇的に向上させる強力な武器となります。基本的なカラーパレット管理から、高度なマルチブランド対応まで、段階的に習得することで、保守性が高く美しい Web アプリケーションを構築できるようになります。

特に重要なのは、チーム全体でのデザインシステム共有です。統一されたテーマを活用することで、デザイナーとエンジニアの連携がスムーズになり、ブランドの一貫性を保ちながら開発スピードも向上させることができるのです。

今回ご紹介したパターンを参考に、あなたのプロジェクトに最適なテーマシステムを構築してみてください。きっと、開発の効率性と品質の両方において、大きな変化を実感していただけることでしょう。

関連リンク