T-CREATOR

Emotion と React の親和性:実践コンポーネント設計術

Emotion と React の親和性:実践コンポーネント設計術

React でスタイリングを行う際、多くの開発者が直面する課題があります。CSS Modules、Styled Components、そして今回取り上げる Emotion。数ある選択肢の中から、なぜ Emotion と React の組み合わせが特別なのか、実際の開発現場での体験を通してお伝えします。

Emotion は単なる CSS-in-JS ライブラリではありません。React のコンポーネント思想と完璧に調和し、開発者の創造性を最大限に引き出すツールなのです。この記事では、Emotion と React の親和性を深く理解し、実践的なコンポーネント設計の技術を身につけていただきます。

Emotion と React の相性が良い理由

React のコンポーネント思想との親和性

React の核となる思想は「コンポーネントベースの開発」です。UI を小さな部品に分割し、それぞれを独立して管理・再利用するという考え方ですね。Emotion はこの思想と完璧に調和します。

従来の CSS では、スタイルとコンポーネントが分離されていました。しかし、Emotion を使うことで、スタイルがコンポーネントの一部として自然に統合されるのです。

typescript// 従来の CSS アプローチ
// Button.css
.button {
  background: blue;
  color: white;
  padding: 10px 20px;
}

// Button.tsx
import './Button.css';

const Button = ({ children }) => {
  return <button className="button">{children}</button>;
};

Emotion を使った場合、スタイルがコンポーネントと一体になります:

typescript// Emotion を使ったアプローチ
import styled from '@emotion/styled';

const StyledButton = styled.button`
  background: blue;
  color: white;
  padding: 10px 20px;
`;

const Button = ({ children }) => {
  return <StyledButton>{children}</StyledButton>;
};

この違いは、コンポーネントの移動や削除時に顕著に現れます。CSS ファイルの依存関係を気にする必要がなく、コンポーネントとスタイルが常に一緒に管理されるのです。

JSX との自然な統合

Emotion の最大の魅力は、JSX との自然な統合にあります。JavaScript の式や変数を直接スタイルに埋め込むことができ、動的なスタイリングが直感的に実現できます。

typescriptimport styled from '@emotion/styled';

const DynamicButton = styled.button<{ isActive: boolean }>`
  background: ${(props) =>
    props.isActive ? 'green' : 'gray'};
  color: white;
  padding: 10px 20px;
  transition: background 0.3s ease;

  &:hover {
    background: ${(props) =>
      props.isActive ? 'darkgreen' : 'darkgray'};
  }
`;

const App = () => {
  const [isActive, setIsActive] = useState(false);

  return (
    <DynamicButton
      isActive={isActive}
      onClick={() => setIsActive(!isActive)}
    >
      クリックしてください
    </DynamicButton>
  );
};

このように、props を通じて動的にスタイルを変更できるのは、Emotion の強力な特徴です。React の状態管理とスタイルが自然に連携し、複雑な UI の実装が驚くほどシンプルになります。

動的スタイリングの実現

Emotion の真価は、複雑な動的スタイリングを実現する能力にあります。条件分岐、計算、アニメーションなど、従来の CSS では実現が困難だった機能を簡単に実装できます。

typescriptimport styled, { css } from '@emotion/styled';

const Card = styled.div<{
  variant: 'primary' | 'secondary';
  size: 'small' | 'medium' | 'large';
}>`
  border-radius: 8px;
  padding: ${(props) => {
    switch (props.size) {
      case 'small':
        return '8px';
      case 'medium':
        return '16px';
      case 'large':
        return '24px';
      default:
        return '16px';
    }
  }};

  ${(props) =>
    props.variant === 'primary' &&
    css`
      background: linear-gradient(
        135deg,
        #667eea 0%,
        #764ba2 100%
      );
      color: white;
      box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
    `}

  ${(props) =>
    props.variant === 'secondary' &&
    css`
      background: #f8f9fa;
      color: #333;
      border: 1px solid #e9ecef;
    `}
  
  transition: all 0.3s ease;

  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
  }
`;

このような動的スタイリングにより、コンポーネントの表現力が格段に向上します。デザインシステムの構築や、ユーザーインタラクションに応じた UI の変化を、コードレベルで完璧に制御できるようになるのです。

基本的なコンポーネント設計パターン

styled-components を使ったコンポーネント作成

Emotion の styled 関数を使うことで、HTML 要素や React コンポーネントを拡張したスタイル付きコンポーネントを作成できます。これが Emotion の最も基本的で強力な機能です。

まず、シンプルなボタンコンポーネントから始めてみましょう:

typescriptimport styled from '@emotion/styled';

// 基本的なボタンスタイル
const BaseButton = styled.button`
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;

  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }
`;

// プライマリボタン
const PrimaryButton = styled(BaseButton)`
  background: #007bff;
  color: white;
  padding: 12px 24px;

  &:hover:not(:disabled) {
    background: #0056b3;
    transform: translateY(-1px);
  }
`;

// セカンダリボタン
const SecondaryButton = styled(BaseButton)`
  background: transparent;
  color: #007bff;
  border: 2px solid #007bff;
  padding: 10px 22px;

  &:hover:not(:disabled) {
    background: #007bff;
    color: white;
  }
`;

このパターンの利点は、共通のスタイルを BaseButton に定義し、それを継承して異なるバリエーションを作成できることです。DRY(Don't Repeat Yourself)の原則を守りながら、一貫性のあるデザインを実現できます。

props を使った動的スタイリング

Emotion の真髄は、props を通じて動的にスタイルを変更できることです。これにより、同じコンポーネントで様々な見た目を実現できます。

typescriptimport styled from '@emotion/styled';

// サイズとバリアントを props で制御するボタン
const Button = styled.button<{
  size?: 'small' | 'medium' | 'large';
  variant?: 'primary' | 'secondary' | 'danger';
  fullWidth?: boolean;
}>`
  border: none;
  border-radius: 6px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  width: ${(props) => (props.fullWidth ? '100%' : 'auto')};

  // サイズによるスタイル変更
  ${(props) =>
    props.size === 'small' &&
    `
    padding: 8px 16px;
    font-size: 14px;
  `}

  ${(props) =>
    props.size === 'medium' &&
    `
    padding: 12px 24px;
    font-size: 16px;
  `}
  
  ${(props) =>
    props.size === 'large' &&
    `
    padding: 16px 32px;
    font-size: 18px;
  `}
  
  // バリアントによるスタイル変更
  ${(props) =>
    props.variant === 'primary' &&
    `
    background: #007bff;
    color: white;
    
    &:hover:not(:disabled) {
      background: #0056b3;
    }
  `}
  
  ${(props) =>
    props.variant === 'secondary' &&
    `
    background: transparent;
    color: #007bff;
    border: 2px solid #007bff;
    
    &:hover:not(:disabled) {
      background: #007bff;
      color: white;
    }
  `}
  
  ${(props) =>
    props.variant === 'danger' &&
    `
    background: #dc3545;
    color: white;
    
    &:hover:not(:disabled) {
      background: #c82333;
    }
  `}
  
  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }
`;

// 使用例
const App = () => {
  return (
    <div>
      <Button size='small' variant='primary'>
        小さいボタン
      </Button>
      <Button size='medium' variant='secondary'>
        中サイズボタン
      </Button>
      <Button size='large' variant='danger' fullWidth>
        危険な操作
      </Button>
    </div>
  );
};

このアプローチにより、コンポーネントの柔軟性が大幅に向上します。同じコンポーネントで様々な用途に対応でき、コードの重複を避けることができます。

コンポーネントの再利用性を高める設計

再利用可能なコンポーネントを設計する際の重要なポイントは、適切な抽象化とインターフェースの設計です。Emotion を使うことで、これらの設計がより直感的になります。

typescriptimport styled from '@emotion/styled';
import { forwardRef } from 'react';

// 共通のスタイル定義
const commonStyles = {
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  border: 'none',
  borderRadius: '8px',
  cursor: 'pointer',
  transition: 'all 0.2s ease',
  fontFamily: 'inherit',
  textDecoration: 'none',
} as const;

// ベースコンポーネント
const BaseComponent = styled.div`
  ${commonStyles}
`;

// ボタンとして使用可能なコンポーネント
const ButtonComponent = styled.button`
  ${commonStyles}
`;

// リンクとして使用可能なコンポーネント
const LinkComponent = styled.a`
  ${commonStyles}
`;

// 再利用可能なアクションボタン
interface ActionButtonProps {
  variant?: 'primary' | 'secondary' | 'outline';
  size?: 'small' | 'medium' | 'large';
  as?: 'button' | 'a';
  href?: string;
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}

const ActionButton = forwardRef<
  HTMLButtonElement | HTMLAnchorElement,
  ActionButtonProps
>(
  (
    {
      variant = 'primary',
      size = 'medium',
      as = 'button',
      children,
      ...props
    },
    ref
  ) => {
    const Component =
      as === 'a' ? LinkComponent : ButtonComponent;

    return (
      <Component
        ref={ref}
        variant={variant}
        size={size}
        {...props}
      >
        {children}
      </Component>
    );
  }
);

ActionButton.displayName = 'ActionButton';

この設計により、同じコンポーネントをボタンとしてもリンクとしても使用でき、様々なシナリオに対応できます。また、forwardRef を使うことで、親コンポーネントから直接 DOM 要素にアクセスすることも可能になります。

高度なコンポーネント設計テクニック

テーマ機能を使った一貫性のあるデザイン

Emotion のテーマ機能は、アプリケーション全体で一貫したデザインを実現するための強力なツールです。色、フォント、スペーシングなどのデザイントークンを一元管理できます。

まず、テーマの型定義を作成します:

typescript// theme/types.ts
export interface Theme {
  colors: {
    primary: string;
    secondary: string;
    success: string;
    danger: string;
    warning: string;
    info: string;
    light: string;
    dark: string;
    white: string;
    black: string;
  };
  typography: {
    fontFamily: {
      primary: string;
      secondary: string;
    };
    fontSize: {
      xs: string;
      sm: string;
      base: string;
      lg: string;
      xl: string;
      '2xl': string;
    };
    fontWeight: {
      normal: number;
      medium: number;
      semibold: number;
      bold: number;
    };
  };
  spacing: {
    xs: string;
    sm: string;
    md: string;
    lg: string;
    xl: string;
    '2xl': string;
  };
  borderRadius: {
    sm: string;
    md: string;
    lg: string;
    full: string;
  };
  shadows: {
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
}

次に、実際のテーマオブジェクトを作成します:

typescript// theme/theme.ts
import { Theme } from './types';

export const lightTheme: Theme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    danger: '#dc3545',
    warning: '#ffc107',
    info: '#17a2b8',
    light: '#f8f9fa',
    dark: '#343a40',
    white: '#ffffff',
    black: '#000000',
  },
  typography: {
    fontFamily: {
      primary:
        '"Inter", -apple-system, BlinkMacSystemFont, sans-serif',
      secondary: '"Georgia", serif',
    },
    fontSize: {
      xs: '0.75rem',
      sm: '0.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
    },
    fontWeight: {
      normal: 400,
      medium: 500,
      semibold: 600,
      bold: 700,
    },
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
    '2xl': '3rem',
  },
  borderRadius: {
    sm: '0.25rem',
    md: '0.375rem',
    lg: '0.5rem',
    full: '9999px',
  },
  shadows: {
    sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
    xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
  },
};

テーマを使用したコンポーネントの例:

typescriptimport styled from '@emotion/styled';
import { useTheme } from '@emotion/react';

const ThemedButton = styled.button`
  background: ${(props) => props.theme.colors.primary};
  color: ${(props) => props.theme.colors.white};
  font-family: ${(props) =>
    props.theme.typography.fontFamily.primary};
  font-size: ${(props) =>
    props.theme.typography.fontSize.base};
  font-weight: ${(props) =>
    props.theme.typography.fontWeight.medium};
  padding: ${(props) =>
    `${props.theme.spacing.sm} ${props.theme.spacing.md}`};
  border-radius: ${(props) => props.theme.borderRadius.md};
  border: none;
  cursor: pointer;
  transition: all 0.2s ease;
  box-shadow: ${(props) => props.theme.shadows.sm};

  &:hover {
    background: ${(props) => props.theme.colors.dark};
    box-shadow: ${(props) => props.theme.shadows.md};
  }
`;

// テーマプロバイダーの設定
import { ThemeProvider } from '@emotion/react';

const App = () => {
  return (
    <ThemeProvider theme={lightTheme}>
      <ThemedButton>テーマ付きボタン</ThemedButton>
    </ThemeProvider>
  );
};

条件分岐を使ったスタイル切り替え

Emotion の css 関数と条件分岐を組み合わせることで、複雑なスタイルの切り替えを実現できます。

typescriptimport styled, { css } from '@emotion/styled';

interface CardProps {
  variant: 'default' | 'elevated' | 'outlined';
  size: 'small' | 'medium' | 'large';
  interactive?: boolean;
}

const Card = styled.div<CardProps>`
  background: ${(props) => props.theme.colors.white};
  border-radius: ${(props) => props.theme.borderRadius.lg};
  transition: all 0.3s ease;

  // サイズによる条件分岐
  ${(props) => {
    switch (props.size) {
      case 'small':
        return css`
          padding: ${props.theme.spacing.sm};
          font-size: ${props.theme.typography.fontSize.sm};
        `;
      case 'medium':
        return css`
          padding: ${props.theme.spacing.md};
          font-size: ${props.theme.typography.fontSize
            .base};
        `;
      case 'large':
        return css`
          padding: ${props.theme.spacing.lg};
          font-size: ${props.theme.typography.fontSize.lg};
        `;
      default:
        return css`
          padding: ${props.theme.spacing.md};
          font-size: ${props.theme.typography.fontSize
            .base};
        `;
    }
  }}

  // バリアントによる条件分岐
  ${(props) =>
    props.variant === 'default' &&
    css`
      border: 1px solid ${props.theme.colors.light};
      box-shadow: ${props.theme.shadows.sm};
    `}
  
  ${(props) =>
    props.variant === 'elevated' &&
    css`
      border: none;
      box-shadow: ${props.theme.shadows.lg};
    `}
  
  ${(props) =>
    props.variant === 'outlined' &&
    css`
      border: 2px solid ${props.theme.colors.primary};
      box-shadow: none;
    `}
  
  // インタラクティブな要素の場合
  ${(props) =>
    props.interactive &&
    css`
      cursor: pointer;

      &:hover {
        transform: translateY(-2px);
        box-shadow: ${props.theme.shadows.xl};
      }
    `}
`;

コンポーネントの合成と継承

Emotion では、既存のコンポーネントを基に新しいコンポーネントを作成できます。これにより、コードの再利用性と保守性が大幅に向上します。

typescriptimport styled from '@emotion/styled';

// ベースとなるカードコンポーネント
const BaseCard = styled.div`
  background: ${(props) => props.theme.colors.white};
  border-radius: ${(props) => props.theme.borderRadius.lg};
  padding: ${(props) => props.theme.spacing.md};
  box-shadow: ${(props) => props.theme.shadows.sm};
  transition: all 0.3s ease;
`;

// 商品カード(BaseCard を継承)
const ProductCard = styled(BaseCard)`
  display: flex;
  flex-direction: column;
  gap: ${(props) => props.theme.spacing.sm};

  &:hover {
    transform: translateY(-4px);
    box-shadow: ${(props) => props.theme.shadows.lg};
  }
`;

// 商品画像
const ProductImage = styled.img`
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: ${(props) => props.theme.borderRadius.md};
`;

// 商品情報
const ProductInfo = styled.div`
  display: flex;
  flex-direction: column;
  gap: ${(props) => props.theme.spacing.xs};
`;

// 商品タイトル
const ProductTitle = styled.h3`
  margin: 0;
  font-size: ${(props) =>
    props.theme.typography.fontSize.lg};
  font-weight: ${(props) =>
    props.theme.typography.fontWeight.semibold};
  color: ${(props) => props.theme.colors.dark};
`;

// 商品価格
const ProductPrice = styled.span`
  font-size: ${(props) =>
    props.theme.typography.fontSize.xl};
  font-weight: ${(props) =>
    props.theme.typography.fontWeight.bold};
  color: ${(props) => props.theme.colors.primary};
`;

// 使用例
const ProductCardComponent = ({ product }) => {
  return (
    <ProductCard>
      <ProductImage
        src={product.image}
        alt={product.name}
      />
      <ProductInfo>
        <ProductTitle>{product.name}</ProductTitle>
        <ProductPrice>
          ¥{product.price.toLocaleString()}
        </ProductPrice>
      </ProductInfo>
    </ProductCard>
  );
};

パフォーマンスを考慮した設計

不要な再レンダリングの回避

Emotion を使ったコンポーネントでも、React の再レンダリング最適化は重要です。特に動的なスタイルを持つコンポーネントでは、パフォーマンスの影響が大きくなります。

typescriptimport styled from '@emotion/styled';
import { memo, useMemo } from 'react';

// 動的スタイルを持つコンポーネント
const DynamicCard = styled.div<{
  isActive: boolean;
  color: string;
}>`
  background: ${(props) => props.color};
  opacity: ${(props) => (props.isActive ? 1 : 0.7)};
  transform: ${(props) =>
    props.isActive ? 'scale(1.05)' : 'scale(1)'};
  transition: all 0.3s ease;
`;

// 最適化されていないコンポーネント(問題のある例)
const UnoptimizedCard = ({ isActive, color, children }) => {
  return (
    <DynamicCard isActive={isActive} color={color}>
      {children}
    </DynamicCard>
  );
};

// 最適化されたコンポーネント
const OptimizedCard = memo(
  ({ isActive, color, children }) => {
    // 動的スタイルをメモ化
    const dynamicStyles = useMemo(
      () => ({
        backgroundColor: color,
        opacity: isActive ? 1 : 0.7,
        transform: isActive ? 'scale(1.05)' : 'scale(1)',
      }),
      [isActive, color]
    );

    return <div style={dynamicStyles}>{children}</div>;
  }
);

OptimizedCard.displayName = 'OptimizedCard';

CSS-in-JS の最適化テクニック

Emotion のパフォーマンスを最大限に引き出すためのテクニックを紹介します。

typescriptimport styled, { css } from '@emotion/styled';

// 静的スタイルを事前定義
const staticStyles = css`
  border-radius: 8px;
  transition: all 0.3s ease;
  cursor: pointer;
`;

// 動的スタイルを分離
const getDynamicStyles = (
  variant: string,
  size: string
) => css`
  ${variant === 'primary' &&
  css`
    background: #007bff;
    color: white;
  `}

  ${variant === 'secondary' &&
  css`
    background: #6c757d;
    color: white;
  `}
  
  ${size === 'small' &&
  css`
    padding: 8px 16px;
    font-size: 14px;
  `}
  
  ${size === 'large' &&
  css`
    padding: 16px 32px;
    font-size: 18px;
  `}
`;

// 最適化されたボタンコンポーネント
const OptimizedButton = styled.button<{
  variant: string;
  size: string;
}>`
  ${staticStyles}
  ${(props) => getDynamicStyles(props.variant, props.size)}
`;

// 使用例
const App = () => {
  return (
    <div>
      <OptimizedButton variant='primary' size='small'>
        プライマリボタン
      </OptimizedButton>
      <OptimizedButton variant='secondary' size='large'>
        セカンダリボタン
      </OptimizedButton>
    </div>
  );
};

バンドルサイズの削減方法

Emotion のバンドルサイズを最適化するためのテクニックを紹介します。

typescript// 1. 未使用スタイルの削除
// emotion.config.js
module.exports = {
  sourceMap: false, // 本番環境では無効化
  autoLabel: 'dev-only', // 開発環境でのみラベルを生成
  labelFormat: '[local]',
  cssPropOptimization: true, // CSS prop の最適化を有効化
};

// 2. 動的インポートの活用
import { lazy, Suspense } from 'react';

// 重いコンポーネントを遅延読み込み
const HeavyComponent = lazy(
  () => import('./HeavyComponent')
);

const App = () => {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <HeavyComponent />
    </Suspense>
  );
};

// 3. スタイルの共有と再利用
// shared-styles.ts
import { css } from '@emotion/react';

export const commonButtonStyles = css`
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
  font-weight: 500;
`;

export const primaryButtonStyles = css`
  ${commonButtonStyles}
  background: #007bff;
  color: white;

  &:hover {
    background: #0056b3;
  }
`;

// 4. 条件付きスタイルの最適化
const ConditionalButton = styled.button<{
  variant: 'primary' | 'secondary';
}>`
  ${commonButtonStyles}

  ${(props) =>
    props.variant === 'primary' && primaryButtonStyles}
  
  ${(props) =>
    props.variant === 'secondary' &&
    css`
      background: transparent;
      color: #007bff;
      border: 2px solid #007bff;

      &:hover {
        background: #007bff;
        color: white;
      }
    `}
`;

実践的なコンポーネント例

ボタンコンポーネントの設計

実際のプロジェクトで使用できる、完全なボタンコンポーネントシステムを構築してみましょう。

typescript// components/Button/types.ts
export interface ButtonProps {
  variant?:
    | 'primary'
    | 'secondary'
    | 'outline'
    | 'ghost'
    | 'danger';
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  loading?: boolean;
  disabled?: boolean;
  fullWidth?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  children: React.ReactNode;
  onClick?: (
    event: React.MouseEvent<HTMLButtonElement>
  ) => void;
  type?: 'button' | 'submit' | 'reset';
}

// components/Button/styles.ts
import styled, { css } from '@emotion/styled';

const baseButtonStyles = css`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  border-radius: 6px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  text-decoration: none;
  white-space: nowrap;

  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    pointer-events: none;
  }

  &:focus {
    outline: 2px solid #007bff;
    outline-offset: 2px;
  }
`;

const sizeStyles = {
  xs: css`
    padding: 4px 8px;
    font-size: 12px;
    min-height: 24px;
  `,
  sm: css`
    padding: 6px 12px;
    font-size: 14px;
    min-height: 32px;
  `,
  md: css`
    padding: 8px 16px;
    font-size: 16px;
    min-height: 40px;
  `,
  lg: css`
    padding: 12px 20px;
    font-size: 18px;
    min-height: 48px;
  `,
  xl: css`
    padding: 16px 24px;
    font-size: 20px;
    min-height: 56px;
  `,
};

const variantStyles = {
  primary: css`
    background: #007bff;
    color: white;

    &:hover:not(:disabled) {
      background: #0056b3;
      transform: translateY(-1px);
    }

    &:active:not(:disabled) {
      transform: translateY(0);
    }
  `,
  secondary: css`
    background: #6c757d;
    color: white;

    &:hover:not(:disabled) {
      background: #545b62;
      transform: translateY(-1px);
    }
  `,
  outline: css`
    background: transparent;
    color: #007bff;
    border: 2px solid #007bff;

    &:hover:not(:disabled) {
      background: #007bff;
      color: white;
    }
  `,
  ghost: css`
    background: transparent;
    color: #007bff;

    &:hover:not(:disabled) {
      background: rgba(0, 123, 255, 0.1);
    }
  `,
  danger: css`
    background: #dc3545;
    color: white;

    &:hover:not(:disabled) {
      background: #c82333;
      transform: translateY(-1px);
    }
  `,
};

export const StyledButton = styled.button<ButtonProps>`
  ${baseButtonStyles}
  ${(props) => sizeStyles[props.size || 'md']}
  ${(props) => variantStyles[props.variant || 'primary']}
  ${(props) =>
    props.fullWidth &&
    css`
      width: 100%;
    `}
  ${(props) =>
    props.loading &&
    css`
      position: relative;
      color: transparent;

      &::after {
        content: '';
        position: absolute;
        width: 16px;
        height: 16px;
        border: 2px solid transparent;
        border-top: 2px solid currentColor;
        border-radius: 50%;
        animation: spin 1s linear infinite;
      }

      @keyframes spin {
        0% {
          transform: rotate(0deg);
        }
        100% {
          transform: rotate(360deg);
        }
      }
    `}
`;

// components/Button/Button.tsx
import React from 'react';
import { StyledButton } from './styles';
import { ButtonProps } from './types';

export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled = false,
  fullWidth = false,
  leftIcon,
  rightIcon,
  children,
  onClick,
  type = 'button',
  ...props
}) => {
  const isDisabled = disabled || loading;

  return (
    <StyledButton
      variant={variant}
      size={size}
      loading={loading}
      disabled={isDisabled}
      fullWidth={fullWidth}
      onClick={onClick}
      type={type}
      {...props}
    >
      {leftIcon && !loading && leftIcon}
      {children}
      {rightIcon && !loading && rightIcon}
    </StyledButton>
  );
};

カードコンポーネントの実装

再利用可能で柔軟なカードコンポーネントを実装してみましょう。

typescript// components/Card/types.ts
export interface CardProps {
  variant?: 'default' | 'elevated' | 'outlined' | 'filled';
  size?: 'sm' | 'md' | 'lg';
  interactive?: boolean;
  hoverable?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

// components/Card/styles.ts
import styled, { css } from '@emotion/styled';

const baseCardStyles = css`
  background: white;
  border-radius: 8px;
  transition: all 0.3s ease;
  overflow: hidden;
`;

const sizeStyles = {
  sm: css`
    padding: 12px;
  `,
  md: css`
    padding: 16px;
  `,
  lg: css`
    padding: 24px;
  `,
};

const variantStyles = {
  default: css`
    border: 1px solid #e9ecef;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  `,
  elevated: css`
    border: none;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  `,
  outlined: css`
    border: 2px solid #007bff;
    box-shadow: none;
  `,
  filled: css`
    border: none;
    background: #f8f9fa;
    box-shadow: none;
  `,
};

export const StyledCard = styled.div<CardProps>`
  ${baseCardStyles}
  ${(props) => sizeStyles[props.size || 'md']}
  ${(props) => variantStyles[props.variant || 'default']}
  
  ${(props) =>
    props.interactive &&
    css`
      cursor: pointer;
    `}
  
  ${(props) =>
    props.hoverable &&
    css`
      &:hover {
        transform: translateY(-4px);
        box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
      }
    `}
`;

// components/Card/Card.tsx
import React from 'react';
import { StyledCard } from './styles';
import { CardProps } from './types';

export const Card: React.FC<CardProps> = ({
  variant = 'default',
  size = 'md',
  interactive = false,
  hoverable = false,
  children,
  onClick,
  ...props
}) => {
  return (
    <StyledCard
      variant={variant}
      size={size}
      interactive={interactive}
      hoverable={hoverable}
      onClick={onClick}
      {...props}
    >
      {children}
    </StyledCard>
  );
};

// カードのサブコンポーネント
export const CardHeader = styled.div`
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e9ecef;
`;

export const CardTitle = styled.h3`
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  color: #212529;
`;

export const CardSubtitle = styled.p`
  margin: 4px 0 0 0;
  font-size: 14px;
  color: #6c757d;
`;

export const CardBody = styled.div`
  color: #495057;
  line-height: 1.6;
`;

export const CardFooter = styled.div`
  margin-top: 16px;
  padding-top: 12px;
  border-top: 1px solid #e9ecef;
  display: flex;
  justify-content: space-between;
  align-items: center;
`;

フォームコンポーネントの作成

ユーザビリティを重視したフォームコンポーネントを実装してみましょう。

typescript// components/Form/types.ts
export interface InputProps {
  type?:
    | 'text'
    | 'email'
    | 'password'
    | 'number'
    | 'tel'
    | 'url';
  placeholder?: string;
  value?: string;
  defaultValue?: string;
  error?: string;
  disabled?: boolean;
  required?: boolean;
  fullWidth?: boolean;
  size?: 'sm' | 'md' | 'lg';
  onChange?: (
    event: React.ChangeEvent<HTMLInputElement>
  ) => void;
  onFocus?: (
    event: React.FocusEvent<HTMLInputElement>
  ) => void;
  onBlur?: (
    event: React.FocusEvent<HTMLInputElement>
  ) => void;
}

// components/Form/styles.ts
import styled, { css } from '@emotion/styled';

const baseInputStyles = css`
  width: 100%;
  border: 2px solid #e9ecef;
  border-radius: 6px;
  padding: 12px 16px;
  font-size: 16px;
  transition: all 0.2s ease;
  background: white;

  &::placeholder {
    color: #adb5bd;
  }

  &:focus {
    outline: none;
    border-color: #007bff;
    box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
  }

  &:disabled {
    background: #f8f9fa;
    cursor: not-allowed;
    opacity: 0.6;
  }
`;

const sizeStyles = {
  sm: css`
    padding: 8px 12px;
    font-size: 14px;
  `,
  md: css`
    padding: 12px 16px;
    font-size: 16px;
  `,
  lg: css`
    padding: 16px 20px;
    font-size: 18px;
  `,
};

const errorStyles = css`
  border-color: #dc3545;

  &:focus {
    border-color: #dc3545;
    box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
  }
`;

export const StyledInput = styled.input<InputProps>`
  ${baseInputStyles}
  ${(props) => sizeStyles[props.size || 'md']}
  ${(props) => props.error && errorStyles}
  ${(props) =>
    !props.fullWidth &&
    css`
      width: auto;
    `}
`;

export const InputWrapper = styled.div`
  display: flex;
  flex-direction: column;
  gap: 4px;
`;

export const InputLabel = styled.label`
  font-size: 14px;
  font-weight: 500;
  color: #495057;
`;

export const InputError = styled.span`
  font-size: 12px;
  color: #dc3545;
  margin-top: 4px;
`;

export const InputHelp = styled.span`
  font-size: 12px;
  color: #6c757d;
  margin-top: 4px;
`;

// components/Form/Input.tsx
import React from 'react';
import {
  StyledInput,
  InputWrapper,
  InputLabel,
  InputError,
  InputHelp,
} from './styles';
import { InputProps } from './types';

interface InputComponentProps extends InputProps {
  label?: string;
  helpText?: string;
  id?: string;
}

export const Input: React.FC<InputComponentProps> = ({
  label,
  helpText,
  error,
  id,
  ...props
}) => {
  const inputId =
    id ||
    `input-${Math.random().toString(36).substr(2, 9)}`;

  return (
    <InputWrapper>
      {label && (
        <InputLabel htmlFor={inputId}>
          {label}
          {props.required && (
            <span style={{ color: '#dc3545' }}> *</span>
          )}
        </InputLabel>
      )}
      <StyledInput id={inputId} error={error} {...props} />
      {error && <InputError>{error}</InputError>}
      {helpText && !error && (
        <InputHelp>{helpText}</InputHelp>
      )}
    </InputWrapper>
  );
};

// フォームコンテナ
export const Form = styled.form`
  display: flex;
  flex-direction: column;
  gap: 20px;
`;

export const FormRow = styled.div`
  display: flex;
  gap: 16px;

  @media (max-width: 768px) {
    flex-direction: column;
  }
`;

export const FormGroup = styled.div`
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex: 1;
`;

まとめ

Emotion と React の組み合わせは、単なる技術的な選択ではありません。開発者の創造性を最大限に引き出し、ユーザー体験を向上させるための強力なツールです。

この記事で紹介したテクニックを実践することで、以下のような効果が期待できます:

開発効率の向上

  • コンポーネントとスタイルの密結合により、開発速度が大幅に向上
  • 再利用可能なコンポーネントの作成により、コードの重複を削減
  • TypeScript との型安全性により、バグの早期発見が可能

保守性の向上

  • テーマ機能による一貫性のあるデザイン管理
  • コンポーネントベースの設計により、変更の影響範囲を限定
  • 明確な命名規則とファイル構成による可読性の向上

パフォーマンスの最適化

  • 不要な再レンダリングの回避
  • バンドルサイズの最適化
  • 効率的なスタイル計算

ユーザー体験の向上

  • 一貫性のあるデザインシステム
  • スムーズなアニメーションとトランジション
  • アクセシビリティへの配慮

Emotion と React の親和性を理解し、実践的なコンポーネント設計の技術を身につけることで、より良い Web アプリケーションを構築できるようになります。この組み合わせの真価は、開発者の創造性を制限するのではなく、それを最大限に引き出すことにあるのです。

関連リンク