T-CREATOR

Emotion で SVG アイコンや画像にスタイルを適用する

Emotion で SVG アイコンや画像にスタイルを適用する

現代の Web 開発において、SVG アイコンや画像のスタイリングは重要な要素です。特に React アプリケーションでは、動的なスタイリングや状態に応じた見た目の変更が求められることが多くなりました。

CSS-in-JS ライブラリの Emotion を使用することで、これらの課題を効率的に解決できます。従来の CSS ファイルでは実現が困難だった柔軟なスタイリングを、JavaScript の力を借りて実現していきましょう。

背景

モダン Web 開発における SVG・画像スタイリングの重要性

現在の Web アプリケーション開発では、ユーザーエクスペリエンスの向上のため、視覚的な要素が重要な役割を果たしています。特に以下の要求が高まっています。

  • 動的テーマ切り替え: ダークモード・ライトモードの対応
  • インタラクティブ性: ホバーやクリック時の視覚的フィードバック
  • レスポンシブデザイン: デバイスサイズに応じた最適化
  • パフォーマンス最適化: 画像の遅延読み込みやサイズ調整

以下の図で、現代的な Web アプリケーションにおける視覚要素の関係性を示します。

mermaidflowchart TD
  user[ユーザー操作] -->|テーマ切り替え| theme[テーマ状態]
  user -->|デバイス変更| device[デバイス状態]
  user -->|インタラクション| interaction[インタラクション状態]

  theme --> styles[動的スタイル]
  device --> styles
  interaction --> styles

  styles --> svg[SVG アイコン]
  styles --> image[画像要素]

  svg --> ui[UI コンポーネント]
  image --> ui

Emotion が提供する価値

Emotion は CSS-in-JS の中でも特に以下の特徴を持っています。

特徴内容メリット
パフォーマンス必要な CSS のみを生成バンドルサイズの最適化
TypeScript サポート型安全なスタイリング開発時のエラー防止
動的スタイリングJavaScript での条件分岐柔軟な表現力
コンポーネント指向React との親和性保守性の向上

課題

従来の CSS での SVG・画像スタイリングの限界

従来の CSS ファイルを使ったスタイリングでは、以下のような課題がありました。

mermaidflowchart LR
  css[CSS ファイル] -->|静的| fixed[固定スタイル]
  fixed --> limit1[テーマ切り替え困難]
  fixed --> limit2[動的変更不可]
  fixed --> limit3[状態管理複雑]

  limit1 --> problem[開発・保守の困難]
  limit2 --> problem
  limit3 --> problem

主な課題点

1. 静的なスタイル定義

  • CSS は基本的に静的なルールセットのため、JavaScript の状態に応じた動的な変更が困難
  • メディアクエリだけでは対応できない複雑な条件分岐

2. グローバルスコープ問題

  • CSS クラス名の衝突リスク
  • スタイルの影響範囲が予測困難

3. 保守性の問題

  • CSS と JavaScript の分離による保守の困難さ
  • 削除されたコンポーネントに対応する CSS の残存

4. パフォーマンス課題

  • 使用していない CSS の読み込み
  • 動的なクラス名変更によるレンダリング負荷

以下は従来の CSS でテーマ切り替えを実装した際の複雑さを示すコードです。

css/* 従来の CSS - 複雑なテーマ管理 */
.icon {
  width: 24px;
  height: 24px;
  fill: #000000;
}

.icon--dark-theme {
  fill: #ffffff;
}

.icon--primary {
  fill: #0066cc;
}

.icon--primary.icon--dark-theme {
  fill: #4da6ff;
}

/* 状態の組み合わせが増えると管理が困難 */
.icon--hover:hover {
  fill: #0052a3;
}

.icon--hover.icon--dark-theme:hover {
  fill: #66b3ff;
}

このように、状態の組み合わせが増えるほど CSS クラスが複雑になり、保守性が著しく低下します。

解決策

Emotion による動的スタイリング手法

Emotion を使用することで、これらの課題を以下のアプローチで解決できます。

mermaidflowchart TD
  emotion[Emotion] -->|CSS-in-JS| dynamic[動的スタイル]
  emotion -->|スコープ化| scoped[局所化スタイル]
  emotion -->|TypeScript| typed[型安全性]

  dynamic --> solution1[状態連動スタイル]
  scoped --> solution2[名前衝突回避]
  typed --> solution3[開発効率向上]

  solution1 --> benefit[開発・保守性向上]
  solution2 --> benefit
  solution3 --> benefit

基本的なセットアップ

まず、Emotion の必要なパッケージをインストールします。

bash# Emotion の基本パッケージをインストール
yarn add @emotion/react @emotion/styled

Next.js プロジェクトの場合は、追加で設定が必要です。

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

module.exports = nextConfig;

TypeScript を使用している場合は、型定義も追加します。

typescript// types/emotion.d.ts - Emotion の型定義拡張
import '@emotion/react';

declare module '@emotion/react' {
  export interface Theme {
    colors: {
      primary: string;
      secondary: string;
      background: string;
      text: string;
    };
    breakpoints: {
      mobile: string;
      tablet: string;
      desktop: string;
    };
  }
}

基本的な使い方

Emotion の css プロパティを使った基本的なスタイリング方法を確認しましょう。

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

const iconStyle = css`
  width: 24px;
  height: 24px;
  fill: #0066cc;
  transition: fill 0.2s ease;
`;

具体例

SVG アイコンのスタイリング実装

基本的な SVG アイコンコンポーネント

まず、基本的な SVG アイコンコンポーネントを作成します。

typescript// components/Icon/Icon.tsx - 基本的なアイコンコンポーネント
import React from 'react';
import { css } from '@emotion/react';

interface IconProps {
  name: string;
  size?: number;
  color?: string;
}

const iconBaseStyle = css`
  display: inline-block;
  vertical-align: middle;
  fill: currentColor;
  transition: all 0.2s ease;
`;

アイコンのサイズと色を動的に制御する関数を定義します。

typescript// components/Icon/Icon.tsx - 動的スタイル関数
const getIconStyle = (size: number, color?: string) => css`
  width: ${size}px;
  height: ${size}px;
  ${color && `fill: ${color};`}
`;

完全なアイコンコンポーネントの実装です。

typescript// components/Icon/Icon.tsx - 完全な実装
export const Icon: React.FC<IconProps> = ({
  name,
  size = 24,
  color,
}) => {
  return (
    <svg
      css={[iconBaseStyle, getIconStyle(size, color)]}
      aria-hidden='true'
    >
      <use href={`#icon-${name}`} />
    </svg>
  );
};

インタラクティブなアイコンボタン

ホバーやアクティブ状態に応じて変化するアイコンボタンを作成します。

typescript// components/IconButton/IconButton.tsx - インタラクティブなボタン
import React from 'react';
import { css, useTheme } from '@emotion/react';

interface IconButtonProps {
  icon: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
}

テーマに応じた動的スタイリングを実装します。

typescript// components/IconButton/IconButton.tsx - テーマ対応スタイル
const getButtonStyle = (
  variant: string,
  theme: any,
  disabled: boolean
) => css`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px;
  border: none;
  border-radius: 4px;
  background-color: ${getVariantColor(variant, theme)};
  cursor: ${disabled ? 'not-allowed' : 'pointer'};
  opacity: ${disabled ? 0.5 : 1};
  transition: all 0.2s ease;

  &:hover:not(:disabled) {
    background-color: ${getVariantHoverColor(
      variant,
      theme
    )};
    transform: translateY(-1px);
  }

  &:active:not(:disabled) {
    transform: translateY(0);
  }
`;

バリアント(種類)に応じた色の制御関数を定義します。

typescript// components/IconButton/IconButton.tsx - 色制御関数
const getVariantColor = (variant: string, theme: any) => {
  switch (variant) {
    case 'primary':
      return theme.colors.primary;
    case 'secondary':
      return theme.colors.secondary;
    case 'danger':
      return '#dc3545';
    default:
      return theme.colors.primary;
  }
};

const getVariantHoverColor = (
  variant: string,
  theme: any
) => {
  switch (variant) {
    case 'primary':
      return '#0052a3';
    case 'secondary':
      return '#5a6268';
    case 'danger':
      return '#c82333';
    default:
      return '#0052a3';
  }
};

完全なアイコンボタンコンポーネントの実装です。

typescript// components/IconButton/IconButton.tsx - 完全な実装
export const IconButton: React.FC<IconButtonProps> = ({
  icon,
  onClick,
  variant = 'primary',
  disabled = false,
}) => {
  const theme = useTheme();

  return (
    <button
      css={getButtonStyle(variant, theme, disabled)}
      onClick={onClick}
      disabled={disabled}
      type='button'
    >
      <Icon name={icon} size={20} />
    </button>
  );
};

画像の responsive スタイリング

基本的な responsive 画像コンポーネント

デバイスサイズに応じて最適化される画像コンポーネントを作成します。

typescript// components/ResponsiveImage/ResponsiveImage.tsx - 基本構造
import React from 'react';
import { css } from '@emotion/react';

interface ResponsiveImageProps {
  src: string;
  alt: string;
  aspectRatio?: number;
  objectFit?: 'cover' | 'contain' | 'fill';
  loading?: 'lazy' | 'eager';
}

responsive な画像のベーススタイルを定義します。

typescript// components/ResponsiveImage/ResponsiveImage.tsx - ベーススタイル
const imageBaseStyle = css`
  width: 100%;
  height: auto;
  display: block;
  border-radius: 8px;
  transition: transform 0.3s ease, box-shadow 0.3s ease;

  &:hover {
    transform: scale(1.02);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  }
`;

アスペクト比を制御する関数を実装します。

typescript// components/ResponsiveImage/ResponsiveImage.tsx - アスペクト比制御
const getAspectRatioStyle = (
  aspectRatio?: number,
  objectFit?: string
) => {
  if (!aspectRatio) return css``;

  return css`
    aspect-ratio: ${aspectRatio};
    object-fit: ${objectFit || 'cover'};
  `;
};

レスポンシブ対応のメディアクエリスタイルを定義します。

typescript// components/ResponsiveImage/ResponsiveImage.tsx - メディアクエリ
const responsiveStyle = css`
  /* モバイル */
  @media (max-width: 768px) {
    border-radius: 4px;

    &:hover {
      transform: none;
      box-shadow: none;
    }
  }

  /* タブレット */
  @media (min-width: 769px) and (max-width: 1024px) {
    border-radius: 6px;
  }

  /* デスクトップ */
  @media (min-width: 1025px) {
    border-radius: 8px;
  }
`;

完全な responsive 画像コンポーネントの実装です。

typescript// components/ResponsiveImage/ResponsiveImage.tsx - 完全な実装
export const ResponsiveImage: React.FC<
  ResponsiveImageProps
> = ({
  src,
  alt,
  aspectRatio,
  objectFit = 'cover',
  loading = 'lazy',
}) => {
  return (
    <img
      src={src}
      alt={alt}
      loading={loading}
      css={[
        imageBaseStyle,
        getAspectRatioStyle(aspectRatio, objectFit),
        responsiveStyle,
      ]}
    />
  );
};

画像ギャラリーコンポーネント

複数の画像を responsive に表示するギャラリーコンポーネントを作成します。

typescript// components/ImageGallery/ImageGallery.tsx - ギャラリー構造
import React from 'react';
import { css } from '@emotion/react';
import { ResponsiveImage } from '../ResponsiveImage';

interface ImageItem {
  id: string;
  src: string;
  alt: string;
  caption?: string;
}

interface ImageGalleryProps {
  images: ImageItem[];
  columns?: number;
  gap?: number;
}

グリッドレイアウトのスタイルを定義します。

typescript// components/ImageGallery/ImageGallery.tsx - グリッドスタイル
const getGalleryStyle = (
  columns: number,
  gap: number
) => css`
  display: grid;
  grid-template-columns: repeat(${columns}, 1fr);
  gap: ${gap}px;

  /* レスポンシブ対応 */
  @media (max-width: 768px) {
    grid-template-columns: 1fr;
    gap: ${Math.max(gap - 8, 8)}px;
  }

  @media (min-width: 769px) and (max-width: 1024px) {
    grid-template-columns: repeat(
      ${Math.min(columns, 2)},
      1fr
    );
    gap: ${Math.max(gap - 4, 12)}px;
  }
`;

個別の画像アイテムのスタイルを定義します。

typescript// components/ImageGallery/ImageGallery.tsx - 画像アイテムスタイル
const imageItemStyle = css`
  position: relative;
  overflow: hidden;

  &::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: linear-gradient(
      135deg,
      rgba(255, 255, 255, 0.1),
      rgba(255, 255, 255, 0)
    );
    opacity: 0;
    transition: opacity 0.3s ease;
  }

  &:hover::after {
    opacity: 1;
  }
`;

完全な画像ギャラリーコンポーネントの実装です。

typescript// components/ImageGallery/ImageGallery.tsx - 完全な実装
export const ImageGallery: React.FC<ImageGalleryProps> = ({
  images,
  columns = 3,
  gap = 16,
}) => {
  return (
    <div css={getGalleryStyle(columns, gap)}>
      {images.map((image) => (
        <div key={image.id} css={imageItemStyle}>
          <ResponsiveImage
            src={image.src}
            alt={image.alt}
            aspectRatio={1}
            objectFit='cover'
          />
          {image.caption && (
            <p css={captionStyle}>{image.caption}</p>
          )}
        </div>
      ))}
    </div>
  );
};

const captionStyle = css`
  margin-top: 8px;
  font-size: 14px;
  color: #666;
  text-align: center;
`;

hover・active 状態の動的変更

高度なインタラクション状態管理

複数の状態を組み合わせたインタラクティブなコンポーネントを作成します。

typescript// components/InteractiveCard/InteractiveCard.tsx - 状態管理
import React, { useState } from 'react'
import { css } from '@emotion/react'

interface InteractiveCardProps {
  title: string
  description: string
  imageUrl: string
  onAction: () => void
}

export const InteractiveCard: React.FC<InteractiveCardProps> = ({
  title,
  description,
  imageUrl,
  onAction
}) => {
  const [isHovered, setIsHovered] = useState(false)
  const [isActive, setIsActive] = useState(false)
  const [isFocused, setIsFocused] = useState(false)

状態に応じた動的スタイルを定義します。

typescript// components/InteractiveCard/InteractiveCard.tsx - 動的スタイル
const cardStyle = css`
  position: relative;
  padding: 24px;
  border-radius: 12px;
  background: #ffffff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  transform-origin: center;

  /* 状態に応じたスタイル変更 */
  ${isHovered &&
  css`
    transform: translateY(-4px) scale(1.02);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  `}

  ${isActive &&
  css`
    transform: translateY(-2px) scale(0.98);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  `}

    ${isFocused &&
  css`
    outline: 3px solid #0066cc;
    outline-offset: 2px;
  `}
`;

画像部分の動的スタイルを定義します。

typescript// components/InteractiveCard/InteractiveCard.tsx - 画像スタイル
const imageStyle = css`
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 8px;
  transition: all 0.3s ease;

  ${isHovered &&
  css`
    filter: brightness(1.1) saturate(1.2);
  `}

  ${isActive &&
  css`
    filter: brightness(0.9) saturate(0.8);
  `}
`;

テキスト部分の動的スタイルを定義します。

typescript// components/InteractiveCard/InteractiveCard.tsx - テキストスタイル
const titleStyle = css`
  font-size: 18px;
  font-weight: 600;
  color: #333;
  margin: 16px 0 8px;
  transition: color 0.2s ease;

  ${isHovered &&
  css`
    color: #0066cc;
  `}
`;

const descriptionStyle = css`
  font-size: 14px;
  color: #666;
  line-height: 1.5;
  margin-bottom: 16px;
`;

完全なインタラクティブカードの実装です。

typescript// components/InteractiveCard/InteractiveCard.tsx - 完全な実装
  return (
    <div
      css={cardStyle}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onMouseDown={() => setIsActive(true)}
      onMouseUp={() => setIsActive(false)}
      onFocus={() => setIsFocused(true)}
      onBlur={() => setIsFocused(false)}
      onClick={onAction}
      tabIndex={0}
      role="button"
    >
      <img src={imageUrl} alt={title} css={imageStyle} />
      <h3 css={titleStyle}>{title}</h3>
      <p css={descriptionStyle}>{description}</p>
    </div>
  )
}

アニメーション付きボタンコンポーネント

より複雑なアニメーション効果を持つボタンを作成します。

typescript// components/AnimatedButton/AnimatedButton.tsx - アニメーション定義
import React, { useState } from 'react';
import { css, keyframes } from '@emotion/react';

const pulseAnimation = keyframes`
  0% { transform: scale(1); }
  50% { transform: scale(1.05); }
  100% { transform: scale(1); }
`;

const rippleAnimation = keyframes`
  0% {
    transform: scale(0);
    opacity: 0.6;
  }
  100% {
    transform: scale(4);
    opacity: 0;
  }
`;

ボタンの基本スタイルとアニメーションを定義します。

typescript// components/AnimatedButton/AnimatedButton.tsx - ボタンスタイル
const animatedButtonStyle = css`
  position: relative;
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  background: linear-gradient(135deg, #0066cc, #004c99);
  color: white;
  font-weight: 600;
  cursor: pointer;
  overflow: hidden;
  transition: all 0.3s ease;

  &:hover {
    background: linear-gradient(135deg, #0052a3, #003d7a);
    animation: ${pulseAnimation} 0.6s ease-in-out;
  }

  &:active::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 20px;
    height: 20px;
    background: rgba(255, 255, 255, 0.3);
    border-radius: 50%;
    transform: translate(-50%, -50%);
    animation: ${rippleAnimation} 0.4s ease-out;
  }
`;

図で理解できる要点:

  • Emotion により JavaScript の状態と CSS スタイルが直接連動
  • コンポーネント単位でスタイルが局所化されるため保守性が向上
  • 動的な条件分岐によりテーマやインタラクション状態を柔軟に制御

まとめ

Emotion を使用した SVG アイコンや画像のスタイリングにより、以下のメリットを得ることができました。

実現できたこと

動的スタイリング

  • JavaScript の状態に応じたリアルタイムなスタイル変更
  • テーマ切り替えやインタラクション状態の柔軟な制御

保守性の向上

  • コンポーネント単位でのスタイル局所化
  • TypeScript による型安全なスタイリング

パフォーマンス最適化

  • 必要な CSS のみの生成と適用
  • 動的なスタイル変更による効率的なレンダリング

開発効率の向上

  • CSS-in-JS による開発フローの統一
  • 豊富なツールサポートとエラーハンドリング

開発のポイント

Emotion を効果的に活用するには、以下の点を意識することが重要です。

  1. 段階的な導入: 既存プロジェクトでは部分的に導入し、効果を確認しながら範囲を拡大
  2. コンポーネント設計: 再利用可能なスタイルコンポーネントの作成
  3. パフォーマンス監視: 動的スタイルによるレンダリング負荷の監視
  4. チーム開発: スタイリング規約やベストプラクティスの共有

現代の Web 開発において、Emotion は SVG アイコンや画像のスタイリングを効率的かつ柔軟に行うための強力なツールとなるでしょう。ぜひ実際のプロジェクトで活用してみてください。

関連リンク