T-CREATOR

Storybook × Sass/Styled-Components で美しい UI 量産

Storybook × Sass/Styled-Components で美しい UI 量産

現代の Web 開発において、美しい UI を効率的に量産することは、プロダクトの成功を左右する重要な要素です。しかし、多くの開発者が直面する課題があります。

「デザインの一貫性を保ちながら、開発速度を上げるにはどうすればいいのか?」 「チーム全体で統一された UI コンポーネントを管理するには?」 「デザイナーとエンジニアの連携をスムーズにするには?」

これらの課題を解決する鍵が、StorybookSass/Styled-Componentsの組み合わせにあります。

Storybook は、UI コンポーネントを独立して開発・テスト・ドキュメント化できる強力なツールです。一方、Sass や Styled-Components は、効率的で保守性の高いスタイリングを実現します。

この記事では、Storybook と Sass/Styled-Components を組み合わせることで、美しい UI を効率的に量産する方法を実践的に解説します。初心者の方でも理解しやすいように、段階的に進めていきましょう。

Storybook の基本概念

Storybook は、UI コンポーネントを独立した環境で開発・テストできるツールです。従来の開発では、コンポーネントを実際のアプリケーション内でしか確認できませんでしたが、Storybook を使えば、コンポーネント単体で動作確認ができます。

javascript// 従来の開発方法
// アプリケーション全体を起動してからコンポーネントを確認
// 時間がかかり、他のコンポーネントの影響を受ける

// Storybookを使った開発方法
// コンポーネント単体で即座に確認可能
// 他のコンポーネントの影響を受けない

Sass と Styled-Components の特徴

Sassは、CSS を拡張したプリプロセッサです。変数、ネスト、ミックスインなどの機能により、保守性の高いスタイルを書くことができます。

scss// Sassの基本的な機能
$primary-color: #007bff;
$border-radius: 4px;

.button {
  background-color: $primary-color;
  border-radius: $border-radius;

  &:hover {
    background-color: darken($primary-color, 10%);
  }

  &--large {
    padding: 12px 24px;
    font-size: 18px;
  }
}

Styled-Componentsは、CSS-in-JS ライブラリです。JavaScript 内で CSS を書くことで、動的なスタイリングとコンポーネント指向の開発が可能になります。

javascript// Styled-Componentsの基本的な使い方
import styled from 'styled-components';

const Button = styled.button`
  background-color: ${(props) =>
    props.primary ? '#007bff' : '#6c757d'};
  border-radius: 4px;
  padding: 8px 16px;
  border: none;
  color: white;

  &:hover {
    background-color: ${(props) =>
      props.primary ? '#0056b3' : '#545b62'};
  }
`;

組み合わせによる相乗効果

Storybook と Sass/Styled-Components を組み合わせることで、以下のような相乗効果が生まれます:

  1. 開発効率の向上: コンポーネント単体での開発が可能
  2. デザインの一貫性: 変数やテーマによる統一されたスタイリング
  3. 保守性の向上: モジュール化されたスタイル管理
  4. チーム連携の改善: デザイナーとエンジニアの共通言語

実際のプロジェクトでは、これらの組み合わせにより、開発速度が 2-3 倍向上したという報告もあります。

Sass を使った Storybook 開発環境の構築

それでは、実際に Sass を使った Storybook 開発環境を構築していきましょう。段階的に進めていくことで、確実に理解できます。

プロジェクトの初期設定

まず、新しい React プロジェクトを作成し、必要な依存関係をインストールします。

bash# 新しいReactプロジェクトを作成
yarn create react-app my-ui-library --template typescript

# プロジェクトディレクトリに移動
cd my-ui-library

# Storybookをインストール
yarn add -D @storybook/react @storybook/addon-essentials @storybook/addon-actions @storybook/addon-links

# Sassの依存関係をインストール
yarn add -D sass

Storybook の初期化

Storybook を初期化して、基本的な設定を行います。

bash# Storybookを初期化
npx storybook init

初期化が完了すると、.storybookディレクトリが作成されます。このディレクトリ内のファイルを確認してみましょう。

javascript// .storybook/main.js
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-webpack5',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
};

Sass の設定

Sass を Storybook で使用するために、設定を追加します。

javascript// .storybook/main.js(更新版)
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-webpack5',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  // Sassの設定を追加
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.scss$/,
      use: ['style-loader', 'css-loader', 'sass-loader'],
    });
    return config;
  },
};

基本的なコンポーネントの作成

Sass を使った基本的なボタンコンポーネントを作成してみましょう。

scss// src/components/Button/Button.scss
$primary-color: #007bff;
$secondary-color: #6c757d;
$success-color: #28a745;
$danger-color: #dc3545;

$border-radius: 4px;
$padding-small: 6px 12px;
$padding-medium: 8px 16px;
$padding-large: 12px 24px;

.button {
  display: inline-block;
  font-weight: 400;
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
  border: 1px solid transparent;
  border-radius: $border-radius;
  transition: all 0.15s ease-in-out;

  &:focus {
    outline: 0;
    box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
  }

  &:disabled {
    opacity: 0.65;
    cursor: not-allowed;
  }

  // サイズバリエーション
  &--small {
    padding: $padding-small;
    font-size: 14px;
  }

  &--medium {
    padding: $padding-medium;
    font-size: 16px;
  }

  &--large {
    padding: $padding-large;
    font-size: 18px;
  }

  // カラーバリエーション
  &--primary {
    background-color: $primary-color;
    border-color: $primary-color;
    color: white;

    &:hover:not(:disabled) {
      background-color: darken($primary-color, 10%);
      border-color: darken($primary-color, 10%);
    }
  }

  &--secondary {
    background-color: $secondary-color;
    border-color: $secondary-color;
    color: white;

    &:hover:not(:disabled) {
      background-color: darken($secondary-color, 10%);
      border-color: darken($secondary-color, 10%);
    }
  }

  &--success {
    background-color: $success-color;
    border-color: $success-color;
    color: white;

    &:hover:not(:disabled) {
      background-color: darken($success-color, 10%);
      border-color: darken($success-color, 10%);
    }
  }

  &--danger {
    background-color: $danger-color;
    border-color: $danger-color;
    color: white;

    &:hover:not(:disabled) {
      background-color: darken($danger-color, 10%);
      border-color: darken($danger-color, 10%);
    }
  }
}

次に、React コンポーネントを作成します。

typescript// src/components/Button/Button.tsx
import React from 'react';
import './Button.scss';

export interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'success' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: () => void;
  className?: string;
}

export const Button: React.FC<ButtonProps> = ({
  children,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
  className = '',
}) => {
  const buttonClasses = [
    'button',
    `button--${variant}`,
    `button--${size}`,
    className,
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <button
      className={buttonClasses}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

Storybook ストーリーの作成

作成したコンポーネントの Storybook ストーリーを作成します。

typescript// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: [
        'primary',
        'secondary',
        'success',
        'danger',
      ],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: { type: 'boolean' },
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary',
  },
};

export const Secondary: Story = {
  args: {
    children: 'Secondary Button',
    variant: 'secondary',
  },
};

export const Success: Story = {
  args: {
    children: 'Success Button',
    variant: 'success',
  },
};

export const Danger: Story = {
  args: {
    children: 'Danger Button',
    variant: 'danger',
  },
};

export const Large: Story = {
  args: {
    children: 'Large Button',
    size: 'large',
  },
};

export const Small: Story = {
  args: {
    children: 'Small Button',
    size: 'small',
  },
};

export const Disabled: Story = {
  args: {
    children: 'Disabled Button',
    disabled: true,
  },
};

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

Sass と Storybook の組み合わせでよく発生するエラーとその解決方法を紹介します。

エラー 1: Sass ファイルが読み込まれない

bash# エラーメッセージ
Module parse failed: Unexpected token (1:0)
You may need an appropriate loader to handle this file type.

解決方法: webpack の設定で sass-loader を追加する必要があります。

javascript// .storybook/main.js
module.exports = {
  // ... 他の設定
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.scss$/,
      use: [
        'style-loader',
        'css-loader',
        {
          loader: 'sass-loader',
          options: {
            implementation: require('sass'),
          },
        },
      ],
    });
    return config;
  },
};

エラー 2: 変数が未定義

scss// エラーメッセージ
Undefined variable $primary-color

解決方法: 変数ファイルをインポートするか、グローバル変数を設定します。

scss// src/styles/variables.scss
$primary-color: #007bff;
$secondary-color: #6c757d;
// ... 他の変数

// コンポーネントファイルでインポート
@import '../../styles/variables.scss';

Storybook の起動と確認

設定が完了したら、Storybook を起動して確認してみましょう。

bash# Storybookを起動
yarn storybook

ブラウザでhttp:​/​​/​localhost:6006にアクセスすると、作成したボタンコンポーネントのストーリーが表示されます。

この段階で、Sass を使った基本的な Storybook 開発環境が構築できました。次のセクションでは、Styled-Components を使った方法について詳しく見ていきましょう。

Styled-Components で Storybook を活用する方法

Styled-Components を使った Storybook 開発は、CSS-in-JS の利点を最大限に活かせる方法です。動的なスタイリングとコンポーネント指向の開発が可能になり、より柔軟な UI 開発が実現できます。

Styled-Components のインストールと設定

まず、Styled-Components をプロジェクトにインストールします。

bash# Styled-Componentsをインストール
yarn add styled-components
yarn add -D @types/styled-components

Storybook で Styled-Components を使用するために、設定を更新します。

javascript// .storybook/main.js(Styled-Components対応版)
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-webpack5',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  // Styled-Componentsの設定を追加
  webpackFinal: async (config) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      'styled-components': require.resolve(
        'styled-components'
      ),
    };
    return config;
  },
};

テーマシステムの構築

Styled-Components の強力な機能の一つが、テーマシステムです。グローバルなテーマを定義することで、一貫したデザインを実現できます。

typescript// src/styles/theme.ts
export const theme = {
  colors: {
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    danger: '#dc3545',
    warning: '#ffc107',
    info: '#17a2b8',
    light: '#f8f9fa',
    dark: '#343a40',
    white: '#ffffff',
    black: '#000000',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
    xxl: '48px',
  },
  borderRadius: {
    sm: '2px',
    md: '4px',
    lg: '8px',
    xl: '16px',
    round: '50%',
  },
  typography: {
    fontSizes: {
      xs: '12px',
      sm: '14px',
      md: '16px',
      lg: '18px',
      xl: '20px',
      xxl: '24px',
    },
    fontWeights: {
      light: 300,
      normal: 400,
      medium: 500,
      bold: 700,
    },
  },
  shadows: {
    sm: '0 1px 3px rgba(0,0,0,0.12)',
    md: '0 4px 6px rgba(0,0,0,0.1)',
    lg: '0 10px 15px rgba(0,0,0,0.1)',
  },
  breakpoints: {
    mobile: '320px',
    tablet: '768px',
    desktop: '1024px',
    wide: '1200px',
  },
};

export type Theme = typeof theme;

Styled-Components を使ったボタンコンポーネント

テーマシステムを活用した、より柔軟なボタンコンポーネントを作成してみましょう。

typescript// src/components/StyledButton/StyledButton.tsx
import React from 'react';
import styled, { css } from 'styled-components';

export interface StyledButtonProps {
  children: React.ReactNode;
  variant?:
    | 'primary'
    | 'secondary'
    | 'success'
    | 'danger'
    | 'warning'
    | 'info';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  fullWidth?: boolean;
  onClick?: () => void;
}

// ボタンのベーススタイル
const BaseButton = styled.button<StyledButtonProps>`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-weight: ${({ theme }) =>
    theme.typography.fontWeights.medium};
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
  border: 1px solid transparent;
  border-radius: ${({ theme }) => theme.borderRadius.md};
  transition: all 0.15s ease-in-out;
  text-decoration: none;
  user-select: none;

  ${({ fullWidth }) =>
    fullWidth &&
    css`
      width: 100%;
    `}

  &:focus {
    outline: 0;
    box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
  }

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

// サイズバリエーション
const sizeVariants = {
  small: css`
    padding: ${({ theme }) =>
      `${theme.spacing.xs} ${theme.spacing.sm}`};
    font-size: ${({ theme }) =>
      theme.typography.fontSizes.sm};
  `,
  medium: css`
    padding: ${({ theme }) =>
      `${theme.spacing.sm} ${theme.spacing.md}`};
    font-size: ${({ theme }) =>
      theme.typography.fontSizes.md};
  `,
  large: css`
    padding: ${({ theme }) =>
      `${theme.spacing.md} ${theme.spacing.lg}`};
    font-size: ${({ theme }) =>
      theme.typography.fontSizes.lg};
  `,
};

// カラーバリエーション
const colorVariants = {
  primary: css`
    background-color: ${({ theme }) =>
      theme.colors.primary};
    border-color: ${({ theme }) => theme.colors.primary};
    color: ${({ theme }) => theme.colors.white};

    &:hover:not(:disabled) {
      background-color: #0056b3;
      border-color: #0056b3;
    }
  `,
  secondary: css`
    background-color: ${({ theme }) =>
      theme.colors.secondary};
    border-color: ${({ theme }) => theme.colors.secondary};
    color: ${({ theme }) => theme.colors.white};

    &:hover:not(:disabled) {
      background-color: #545b62;
      border-color: #545b62;
    }
  `,
  success: css`
    background-color: ${({ theme }) =>
      theme.colors.success};
    border-color: ${({ theme }) => theme.colors.success};
    color: ${({ theme }) => theme.colors.white};

    &:hover:not(:disabled) {
      background-color: #1e7e34;
      border-color: #1e7e34;
    }
  `,
  danger: css`
    background-color: ${({ theme }) => theme.colors.danger};
    border-color: ${({ theme }) => theme.colors.danger};
    color: ${({ theme }) => theme.colors.white};

    &:hover:not(:disabled) {
      background-color: #c82333;
      border-color: #c82333;
    }
  `,
  warning: css`
    background-color: ${({ theme }) =>
      theme.colors.warning};
    border-color: ${({ theme }) => theme.colors.warning};
    color: ${({ theme }) => theme.colors.dark};

    &:hover:not(:disabled) {
      background-color: #e0a800;
      border-color: #e0a800;
    }
  `,
  info: css`
    background-color: ${({ theme }) => theme.colors.info};
    border-color: ${({ theme }) => theme.colors.info};
    color: ${({ theme }) => theme.colors.white};

    &:hover:not(:disabled) {
      background-color: #138496;
      border-color: #138496;
    }
  `,
};

const StyledButton = styled(BaseButton)`
  ${({ size = 'medium' }) => sizeVariants[size]}
  ${({ variant = 'primary' }) => colorVariants[variant]}
`;

export const StyledButtonComponent: React.FC<
  StyledButtonProps
> = ({
  children,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  fullWidth = false,
  onClick,
  ...props
}) => {
  return (
    <StyledButton
      variant={variant}
      size={size}
      disabled={disabled}
      fullWidth={fullWidth}
      onClick={onClick}
      {...props}
    >
      {children}
    </StyledButton>
  );
};

Storybook でのテーマ提供

Styled-Components のテーマを Storybook で使用するために、ThemeProvider を設定します。

typescript// .storybook/preview.tsx
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { theme } from '../src/styles/theme';

export const decorators = [
  (Story) => (
    <ThemeProvider theme={theme}>
      <Story />
    </ThemeProvider>
  ),
];

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
};

Styled-Components の Storybook ストーリー

テーマシステムを活用したストーリーを作成します。

typescript// src/components/StyledButton/StyledButton.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { StyledButtonComponent } from './StyledButton';

const meta: Meta<typeof StyledButtonComponent> = {
  title: 'Components/StyledButton',
  component: StyledButtonComponent,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: [
        'primary',
        'secondary',
        'success',
        'danger',
        'warning',
        'info',
      ],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: { type: 'boolean' },
    },
    fullWidth: {
      control: { type: 'boolean' },
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary',
  },
};

export const AllVariants: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '8px',
        flexWrap: 'wrap',
      }}
    >
      <StyledButtonComponent variant='primary'>
        Primary
      </StyledButtonComponent>
      <StyledButtonComponent variant='secondary'>
        Secondary
      </StyledButtonComponent>
      <StyledButtonComponent variant='success'>
        Success
      </StyledButtonComponent>
      <StyledButtonComponent variant='danger'>
        Danger
      </StyledButtonComponent>
      <StyledButtonComponent variant='warning'>
        Warning
      </StyledButtonComponent>
      <StyledButtonComponent variant='info'>
        Info
      </StyledButtonComponent>
    </div>
  ),
};

export const AllSizes: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '8px',
        alignItems: 'center',
      }}
    >
      <StyledButtonComponent size='small'>
        Small
      </StyledButtonComponent>
      <StyledButtonComponent size='medium'>
        Medium
      </StyledButtonComponent>
      <StyledButtonComponent size='large'>
        Large
      </StyledButtonComponent>
    </div>
  ),
};

export const FullWidth: Story = {
  args: {
    children: 'Full Width Button',
    fullWidth: true,
  },
};

export const Disabled: Story = {
  args: {
    children: 'Disabled Button',
    disabled: true,
  },
};

よくあるエラーと解決方法(Styled-Components)

Styled-Components と Storybook の組み合わせで発生するエラーと解決方法を紹介します。

エラー 1: テーマが未定義

bash# エラーメッセージ
Cannot read property 'colors' of undefined

解決方法: ThemeProvider でテーマを提供する必要があります。

typescript// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components';
import { theme } from '../src/styles/theme';

export const decorators = [
  (Story) => (
    <ThemeProvider theme={theme}>
      <Story />
    </ThemeProvider>
  ),
];

エラー 2: Styled-Components の型エラー

bash# エラーメッセージ
Property 'theme' does not exist on type 'DefaultTheme'

解決方法: 型定義を拡張します。

typescript// src/types/styled.d.ts
import 'styled-components';
import { theme } from '../styles/theme';

type Theme = typeof theme;

declare module 'styled-components' {
  export interface DefaultTheme extends Theme {}
}

デザインシステムの構築と Storybook の連携

デザインシステムは、一貫した UI を効率的に開発するための基盤です。Storybook と組み合わせることで、デザインシステムの構築と運用が格段に効率化されます。

デザインシステムの基本概念

デザインシステムは、以下の要素で構成されます:

  1. デザイントークン: 色、フォント、スペーシングなどの基本値
  2. コンポーネント: 再利用可能な UI 要素
  3. パターン: コンポーネントの組み合わせルール
  4. ドキュメント: 使用方法とガイドライン

デザイントークンの管理

デザイントークンを一元管理することで、一貫したデザインを実現できます。

typescript// src/design-tokens/index.ts
export const designTokens = {
  colors: {
    brand: {
      primary: '#007bff',
      secondary: '#6c757d',
      accent: '#17a2b8',
    },
    semantic: {
      success: '#28a745',
      warning: '#ffc107',
      error: '#dc3545',
      info: '#17a2b8',
    },
    neutral: {
      white: '#ffffff',
      gray50: '#f8f9fa',
      gray100: '#e9ecef',
      gray200: '#dee2e6',
      gray300: '#ced4da',
      gray400: '#adb5bd',
      gray500: '#6c757d',
      gray600: '#495057',
      gray700: '#343a40',
      gray800: '#212529',
      gray900: '#000000',
    },
  },
  typography: {
    fontFamily: {
      primary:
        "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
      mono: "'SF Mono', Monaco, 'Cascadia 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,
    },
    lineHeight: {
      tight: 1.25,
      normal: 1.5,
      relaxed: 1.75,
    },
  },
  spacing: {
    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',
  },
  borderRadius: {
    none: '0',
    sm: '0.125rem',
    base: '0.25rem',
    md: '0.375rem',
    lg: '0.5rem',
    xl: '0.75rem',
    '2xl': '1rem',
    full: '9999px',
  },
  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)',
  },
  breakpoints: {
    sm: '640px',
    md: '768px',
    lg: '1024px',
    xl: '1280px',
    '2xl': '1536px',
  },
};

export type DesignTokens = typeof designTokens;

コンポーネントライブラリの構造

効率的なコンポーネント管理のためのディレクトリ構造を提案します。

graphqlsrc/
├── components/
│   ├── atoms/           # 最小単位のコンポーネント
│   │   ├── Button/
│   │   ├── Input/
│   │   └── Icon/
│   ├── molecules/       # atomsの組み合わせ
│   │   ├── SearchBar/
│   │   ├── Card/
│   │   └── Modal/
│   └── organisms/       # moleculesの組み合わせ
│       ├── Header/
│       ├── Footer/
│       └── Sidebar/
├── design-tokens/       # デザイントークン
├── hooks/              # カスタムフック
├── utils/              # ユーティリティ関数
└── styles/             # グローバルスタイル

コンポーネントの命名規則

一貫性のある命名規則を設定することで、チーム開発がスムーズになります。

typescript// コンポーネントの命名規則例
// ファイル名: PascalCase
// Button.tsx, SearchBar.tsx, UserProfile.tsx

// コンポーネント名: PascalCase
export const Button = () => { ... };
export const SearchBar = () => { ... };

// プロパティ名: camelCase
interface ButtonProps {
  variant: 'primary' | 'secondary';
  size: 'small' | 'medium' | 'large';
  onClick: () => void;
}

// CSSクラス名: kebab-case
const StyledButton = styled.button`
  // BEM記法を使用
  &.button--primary { ... }
  &.button--large { ... }
`;

コンポーネントライブラリの効率的な管理術

コンポーネントライブラリを効率的に管理するための実践的なテクニックを紹介します。

コンポーネントの分類と整理

コンポーネントを適切に分類することで、開発効率が向上します。

typescript// src/components/index.ts
// エクスポートの一元管理
export { Button } from './atoms/Button/Button';
export { Input } from './atoms/Input/Input';
export { Card } from './molecules/Card/Card';
export { Header } from './organisms/Header/Header';

// 型定義のエクスポート
export type { ButtonProps } from './atoms/Button/Button';
export type { InputProps } from './atoms/Input/Input';
export type { CardProps } from './molecules/Card/Card';
export type { HeaderProps } from './organisms/Header/Header';

Storybook でのコンポーネント管理

Storybook の階層構造を活用して、コンポーネントを整理します。

typescript// src/components/atoms/Button/Button.stories.tsx
const meta: Meta<typeof Button> = {
  title: 'Design System/Atoms/Button', // 階層構造
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: [
        'primary',
        'secondary',
        'success',
        'danger',
      ],
    },
    size: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
  },
};

コンポーネントのバリエーション管理

コンポーネントのバリエーションを効率的に管理する方法を紹介します。

typescript// src/components/atoms/Button/Button.stories.tsx
// バリエーションの一覧表示
export const AllVariants: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '8px',
        flexWrap: 'wrap',
      }}
    >
      <Button variant='primary'>Primary</Button>
      <Button variant='secondary'>Secondary</Button>
      <Button variant='success'>Success</Button>
      <Button variant='danger'>Danger</Button>
    </div>
  ),
  parameters: {
    docs: {
      description: {
        story: 'すべてのボタンバリエーションを表示します。',
      },
    },
  },
};

// サイズの比較
export const SizeComparison: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '8px',
        alignItems: 'center',
      }}
    >
      <Button size='small'>Small</Button>
      <Button size='medium'>Medium</Button>
      <Button size='large'>Large</Button>
    </div>
  ),
};

コンポーネントのテスト戦略

Storybook と組み合わせたテスト戦略を実装します。

typescript// src/components/atoms/Button/Button.test.tsx
import React from 'react';
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(
      screen.getByText('Click me')
    ).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('applies correct variant class', () => {
    render(
      <Button variant='primary'>Primary Button</Button>
    );
    const button = screen.getByText('Primary Button');
    expect(button).toHaveClass('button--primary');
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled Button</Button>);
    expect(
      screen.getByText('Disabled Button')
    ).toBeDisabled();
  });
});

テーマ機能を活用した UI 量産テクニック

テーマ機能を活用することで、効率的に UI を量産できます。複数のテーマに対応し、動的なテーマ切り替えを実現しましょう。

マルチテーマ対応

複数のテーマを管理し、動的に切り替える仕組みを構築します。

typescript// src/styles/themes/index.ts
import { lightTheme } from './lightTheme';
import { darkTheme } from './darkTheme';
import { brandTheme } from './brandTheme';

export const themes = {
  light: lightTheme,
  dark: darkTheme,
  brand: brandTheme,
};

export type ThemeName = keyof typeof themes;
typescript// src/styles/themes/lightTheme.ts
export const lightTheme = {
  colors: {
    background: '#ffffff',
    surface: '#f8f9fa',
    text: {
      primary: '#212529',
      secondary: '#6c757d',
      disabled: '#adb5bd',
    },
    border: '#dee2e6',
    primary: '#007bff',
    secondary: '#6c757d',
    success: '#28a745',
    warning: '#ffc107',
    error: '#dc3545',
  },
  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)',
  },
};
typescript// src/styles/themes/darkTheme.ts
export const darkTheme = {
  colors: {
    background: '#1a1a1a',
    surface: '#2d2d2d',
    text: {
      primary: '#ffffff',
      secondary: '#b0b0b0',
      disabled: '#666666',
    },
    border: '#404040',
    primary: '#4dabf7',
    secondary: '#868e96',
    success: '#51cf66',
    warning: '#ffd43b',
    error: '#ff6b6b',
  },
  shadows: {
    sm: '0 1px 2px 0 rgba(0, 0, 0, 0.3)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.4)',
  },
};

テーマ切り替え機能

Storybook でテーマを動的に切り替える機能を実装します。

typescript// .storybook/preview.tsx
import React, { useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { themes, ThemeName } from '../src/styles/themes';

// テーマ切り替えコンポーネント
const ThemeSwitcher = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [currentTheme, setCurrentTheme] =
    useState<ThemeName>('light');

  return (
    <div>
      <div
        style={{
          position: 'fixed',
          top: '10px',
          right: '10px',
          zIndex: 9999,
          background: 'white',
          padding: '8px',
          borderRadius: '4px',
          boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
        }}
      >
        <select
          value={currentTheme}
          onChange={(e) =>
            setCurrentTheme(e.target.value as ThemeName)
          }
        >
          <option value='light'>Light Theme</option>
          <option value='dark'>Dark Theme</option>
          <option value='brand'>Brand Theme</option>
        </select>
      </div>
      <ThemeProvider theme={themes[currentTheme]}>
        {children}
      </ThemeProvider>
    </div>
  );
};

export const decorators = [
  (Story) => (
    <ThemeSwitcher>
      <Story />
    </ThemeSwitcher>
  ),
];

テーマ対応コンポーネントの作成

テーマに対応したコンポーネントを作成します。

typescript// src/components/atoms/Card/Card.tsx
import React from 'react';
import styled from 'styled-components';

export interface CardProps {
  children: React.ReactNode;
  variant?: 'default' | 'elevated' | 'outlined';
  padding?: 'small' | 'medium' | 'large';
}

const StyledCard = styled.div<CardProps>`
  background-color: ${({ theme }) => theme.colors.surface};
  border: 1px solid ${({ theme }) => theme.colors.border};
  border-radius: 8px;
  color: ${({ theme }) => theme.colors.text.primary};

  ${({ variant }) => {
    switch (variant) {
      case 'elevated':
        return css`
          box-shadow: ${({ theme }) => theme.shadows.md};
          border: none;
        `;
      case 'outlined':
        return css`
          border: 2px solid ${({ theme }) =>
              theme.colors.border};
        `;
      default:
        return css`
          box-shadow: ${({ theme }) => theme.shadows.sm};
        `;
    }
  }}

  ${({ padding = 'medium' }) => {
    switch (padding) {
      case 'small':
        return css`
          padding: 12px;
        `;
      case 'large':
        return css`
          padding: 24px;
        `;
      default:
        return css`
          padding: 16px;
        `;
    }
  }}
`;

export const Card: React.FC<CardProps> = ({
  children,
  variant = 'default',
  padding = 'medium',
  ...props
}) => {
  return (
    <StyledCard
      variant={variant}
      padding={padding}
      {...props}
    >
      {children}
    </StyledCard>
  );
};

テーマ対応の Storybook ストーリー

テーマ切り替えを確認できるストーリーを作成します。

typescript// src/components/atoms/Card/Card.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Card } from './Card';

const meta: Meta<typeof Card> = {
  title: 'Design System/Atoms/Card',
  component: Card,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['default', 'elevated', 'outlined'],
    },
    padding: {
      control: { type: 'select' },
      options: ['small', 'medium', 'large'],
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: {
    children: 'This is a default card with some content.',
  },
};

export const AllVariants: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '16px',
        flexWrap: 'wrap',
      }}
    >
      <Card variant='default'>
        <h3>Default Card</h3>
        <p>This is a default card variant.</p>
      </Card>
      <Card variant='elevated'>
        <h3>Elevated Card</h3>
        <p>This card has elevation and no border.</p>
      </Card>
      <Card variant='outlined'>
        <h3>Outlined Card</h3>
        <p>This card has a prominent border.</p>
      </Card>
    </div>
  ),
};

export const AllPaddingSizes: Story = {
  render: () => (
    <div
      style={{
        display: 'flex',
        gap: '16px',
        flexWrap: 'wrap',
      }}
    >
      <Card padding='small'>
        <h3>Small Padding</h3>
        <p>Compact card with small padding.</p>
      </Card>
      <Card padding='medium'>
        <h3>Medium Padding</h3>
        <p>Standard card with medium padding.</p>
      </Card>
      <Card padding='large'>
        <h3>Large Padding</h3>
        <p>Spacious card with large padding.</p>
      </Card>
    </div>
  ),
};

パフォーマンス最適化とベストプラクティス

Storybook と Sass/Styled-Components を組み合わせた開発において、パフォーマンスを最適化し、保守性を向上させるベストプラクティスを紹介します。

バンドルサイズの最適化

Storybook のバンドルサイズを最適化するための設定を紹介します。

javascript// .storybook/main.js(最適化版)
module.exports = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-webpack5',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
  // パフォーマンス最適化設定
  webpackFinal: async (config) => {
    // 不要なプラグインを削除
    config.plugins = config.plugins.filter(
      (plugin) =>
        plugin.constructor.name !==
        'ForkTsCheckerWebpackPlugin'
    );

    // バンドル分割の設定
    config.optimization = {
      ...config.optimization,
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
    };

    return config;
  },
};

コンポーネントの遅延読み込み

大きなコンポーネントライブラリでは、遅延読み込みを活用します。

typescript// src/components/lazy/index.ts
import { lazy } from 'react';

// 大きなコンポーネントを遅延読み込み
export const ComplexChart = lazy(
  () => import('../ComplexChart/ComplexChart')
);
export const DataTable = lazy(
  () => import('../DataTable/DataTable')
);
export const RichTextEditor = lazy(
  () => import('../RichTextEditor/RichTextEditor')
);

メモ化によるパフォーマンス向上

React.memo と useMemo を活用してパフォーマンスを向上させます。

typescript// src/components/atoms/Button/Button.tsx(最適化版)
import React, { memo, useMemo } from 'react';
import styled from 'styled-components';

export interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'success' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: () => void;
}

const StyledButton = styled.button<ButtonProps>`
  // ... スタイル定義
`;

export const Button = memo<ButtonProps>(
  ({
    children,
    variant = 'primary',
    size = 'medium',
    disabled = false,
    onClick,
    ...props
  }) => {
    // クラス名の計算をメモ化
    const buttonClasses = useMemo(() => {
      return [
        'button',
        `button--${variant}`,
        `button--${size}`,
      ].join(' ');
    }, [variant, size]);

    return (
      <StyledButton
        className={buttonClasses}
        disabled={disabled}
        onClick={onClick}
        {...props}
      >
        {children}
      </StyledButton>
    );
  }
);

Button.displayName = 'Button';

エラーハンドリングの実装

堅牢なエラーハンドリングを実装します。

typescript// src/components/ErrorBoundary/ErrorBoundary.tsx
import React, {
  Component,
  ErrorInfo,
  ReactNode,
} from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error(
      'ErrorBoundary caught an error:',
      error,
      errorInfo
    );
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div
            style={{
              padding: '16px',
              border: '1px solid #dc3545',
              borderRadius: '4px',
              backgroundColor: '#f8d7da',
              color: '#721c24',
            }}
          >
            <h3>Something went wrong</h3>
            <p>Please try refreshing the page.</p>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

アクセシビリティの向上

アクセシビリティを考慮したコンポーネント設計を実装します。

typescript// src/components/atoms/Button/Button.tsx(アクセシビリティ対応版)
import React, { forwardRef } from 'react';
import styled from 'styled-components';

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'success' | 'danger';
  size?: 'small' | 'medium' | 'large';
  loading?: boolean;
  'aria-label'?: string;
}

const StyledButton = styled.button<ButtonProps>`
  // ... 既存のスタイル

  &:focus-visible {
    outline: 2px solid ${({ theme }) => theme.colors.primary};
    outline-offset: 2px;
  }

  ${({ loading }) =>
    loading &&
    css`
      position: relative;
      color: transparent;

      &::after {
        content: '';
        position: absolute;
        top: 50%;
        left: 50%;
        width: 16px;
        height: 16px;
        margin: -8px 0 0 -8px;
        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);
        }
      }
    `}
`;

export const Button = forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    {
      children,
      variant = 'primary',
      size = 'medium',
      disabled = false,
      loading = false,
      onClick,
      'aria-label': ariaLabel,
      ...props
    },
    ref
  ) => {
    const isDisabled = disabled || loading;

    return (
      <StyledButton
        ref={ref}
        variant={variant}
        size={size}
        disabled={isDisabled}
        loading={loading}
        onClick={onClick}
        aria-label={ariaLabel}
        aria-busy={loading}
        {...props}
      >
        {children}
      </StyledButton>
    );
  }
);

Button.displayName = 'Button';

よくあるエラーと解決方法(パフォーマンス関連)

パフォーマンス関連でよく発生するエラーと解決方法を紹介します。

エラー 1: メモリリーク

bash# エラーメッセージ
Warning: Can't perform a React state update on an unmounted component

解決方法: useEffect のクリーンアップ関数を実装します。

typescriptuseEffect(() => {
  const timer = setTimeout(() => {
    // 何らかの処理
  }, 1000);

  return () => clearTimeout(timer);
}, []);

エラー 2: 不要な再レンダリング

bash# パフォーマンス問題
Component is re-rendering too frequently

解決方法: React.memo と useCallback を活用します。

typescriptconst MemoizedComponent = memo(Component);

const handleClick = useCallback(() => {
  // クリック処理
}, []);

まとめ

Storybook と Sass/Styled-Components を組み合わせることで、美しい UI を効率的に量産する方法を実践的に解説してきました。

主要なポイント

  1. 開発効率の向上: Storybook によるコンポーネント単体開発により、開発速度が 2-3 倍向上
  2. デザインの一貫性: テーマシステムとデザイントークンによる統一されたスタイリング
  3. 保守性の向上: モジュール化されたコンポーネントとスタイル管理
  4. チーム連携の改善: デザイナーとエンジニアの共通言語として機能

実践的なアプローチ

  • 段階的な導入: 小さなコンポーネントから始めて、徐々に拡張
  • テーマシステム: 複数のテーマに対応し、動的な切り替えを実現
  • パフォーマンス最適化: メモ化、遅延読み込み、バンドル最適化
  • アクセシビリティ: 包括的なアクセシビリティ対応

今後の発展

この組み合わせを活用することで、以下のような発展が期待できます:

  • デザインシステムの構築: 企業全体で統一されたデザインシステム
  • コンポーネントライブラリの公開: オープンソースとしての公開
  • 自動化の実現: CI/CD パイプラインとの連携
  • ドキュメントの自動生成: 常に最新のドキュメント維持

Storybook と Sass/Styled-Components の組み合わせは、現代の Web 開発において、美しい UI を効率的に量産するための最強のツールセットです。この記事で紹介した手法を実践することで、開発チーム全体の生産性と品質を大幅に向上させることができるでしょう。

関連リンク