T-CREATOR

Storybook のテーマカスタマイズ術

Storybook のテーマカスタマイズ術

Storybook で UI コンポーネントを開発していると、必ずと言っていいほど直面するのが「テーマの統一」という課題です。

デフォルトのテーマでは物足りない、ブランドカラーを反映したい、ダークモードに対応したい...そんな悩みを抱えている方も多いのではないでしょうか。

この記事では、Storybook のテーマカスタマイズを徹底的に解説します。基本から応用まで、実際のコード例と共に、あなたの Storybook をより魅力的で実用的なものにする方法をお伝えします。

テーマカスタマイズの基本概念

Storybook のテーマカスタマイズとは、UI コンポーネントの見た目を統一し、ブランドの一貫性を保つための仕組みです。

テーマの重要性

テーマカスタマイズが重要な理由は 3 つあります:

  1. ブランドの一貫性: 企業のブランドカラーやデザインガイドラインを反映
  2. 開発効率の向上: 共通のスタイルを一箇所で管理
  3. ユーザー体験の向上: 統一された UI で使いやすいインターフェースを提供

テーマの構成要素

Storybook のテーマは以下の要素で構成されています:

typescriptinterface Theme {
  // カラーパレット
  color: {
    primary: string;
    secondary: string;
    background: string;
    text: string;
  };

  // タイポグラフィ
  typography: {
    fontFamily: string;
    fontSize: Record<string, string>;
    fontWeight: Record<string, number>;
  };

  // スペーシング
  spacing: Record<string, string>;

  // その他のデザイントークン
  borderRadius: Record<string, string>;
  shadows: Record<string, string>;
}

Storybook のテーマシステムを理解する

Storybook には、柔軟で強力なテーマシステムが組み込まれています。

テーマの階層構造

Storybook のテーマは階層的に管理されます:

  1. グローバルテーマ: アプリケーション全体に適用
  2. ストーリーレベルテーマ: 特定のストーリーにのみ適用
  3. コンポーネントレベルテーマ: 個別コンポーネントに適用

テーマプロバイダーの仕組み

テーマプロバイダーは、React の Context API を活用してテーマ情報を子コンポーネントに提供します。

typescript// .storybook/preview.tsx
import { ThemeProvider } from '@storybook/theming';

const lightTheme = {
  base: 'light',
  colorPrimary: '#007acc',
  colorSecondary: '#585c6d',
  // ... その他のテーマ設定
};

const darkTheme = {
  base: 'dark',
  colorPrimary: '#007acc',
  colorSecondary: '#585c6d',
  // ... その他のテーマ設定
};

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

基本的なテーマ設定の実装

まずは、基本的なテーマ設定から始めましょう。

1. テーマ設定ファイルの作成

.storybook​/​theme.tsファイルを作成して、テーマの定義を行います。

typescript// .storybook/theme.ts
export const lightTheme = {
  base: 'light',

  // カラーパレット
  colorPrimary: '#007acc',
  colorSecondary: '#585c6d',

  // UI要素の色
  appBg: '#ffffff',
  appContentBg: '#ffffff',
  barBg: '#f8f9fa',
  barTextColor: '#333333',

  // テキストカラー
  textColor: '#333333',
  textInverseColor: '#ffffff',

  // ボーダーとシャドウ
  barSelectedColor: '#007acc',
  inputBg: '#ffffff',
  inputBorder: '#e1e5e9',
  inputTextColor: '#333333',
};

2. Preview ファイルでの適用

作成したテーマを.storybook​/​preview.tsxで適用します。

typescript// .storybook/preview.tsx
import { Preview } from '@storybook/react';
import { lightTheme } from './theme';

const preview: Preview = {
  parameters: {
    docs: {
      theme: lightTheme,
    },
    backgrounds: {
      default: 'light',
      values: [
        {
          name: 'light',
          value: '#ffffff',
        },
        {
          name: 'dark',
          value: '#333333',
        },
      ],
    },
  },
};

export default preview;

3. よくあるエラーと解決策

エラー 1: ThemeProvider が見つからない

bashModule not found: Can't resolve '@storybook/theming'

解決策: 必要なパッケージをインストールします。

bashyarn add @storybook/theming

エラー 2: テーマが適用されない

typescript// 間違った例
export const parameters = {
  theme: lightTheme, // これは機能しません
};

// 正しい例
export const parameters = {
  docs: {
    theme: lightTheme,
  },
};

カスタムテーマの作成手順

ブランドに合わせたカスタムテーマを作成してみましょう。

1. デザイントークンの定義

まず、デザイントークンを定義します。

typescript// .storybook/design-tokens.ts
export const colors = {
  primary: {
    50: '#eff6ff',
    100: '#dbeafe',
    500: '#3b82f6',
    600: '#2563eb',
    900: '#1e3a8a',
  },
  gray: {
    50: '#f9fafb',
    100: '#f3f4f6',
    500: '#6b7280',
    900: '#111827',
  },
  success: '#10b981',
  warning: '#f59e0b',
  error: '#ef4444',
};

export const typography = {
  fontFamily: {
    sans: ['Inter', 'system-ui', 'sans-serif'],
    mono: ['JetBrains Mono', 'monospace'],
  },
  fontSize: {
    xs: '0.75rem',
    sm: '0.875rem',
    base: '1rem',
    lg: '1.125rem',
    xl: '1.25rem',
  },
};

2. カスタムテーマの構築

デザイントークンを使用してカスタムテーマを構築します。

typescript// .storybook/custom-theme.ts
import { create } from '@storybook/theming/create';
import { colors, typography } from './design-tokens';

export const customTheme = create({
  base: 'light',

  // ブランド情報
  brandTitle: 'My Company Design System',
  brandUrl: 'https://mycompany.com',
  brandImage: '/logo.svg',

  // カラーパレット
  colorPrimary: colors.primary[500],
  colorSecondary: colors.primary[600],

  // UI要素
  appBg: colors.gray[50],
  appContentBg: '#ffffff',
  barBg: '#ffffff',
  barTextColor: colors.gray[900],
  barSelectedColor: colors.primary[500],

  // テキスト
  textColor: colors.gray[900],
  textInverseColor: '#ffffff',

  // フォーム要素
  inputBg: '#ffffff',
  inputBorder: colors.gray[100],
  inputTextColor: colors.gray[900],

  // フォント
  fontBase: typography.fontFamily.sans.join(', '),
  fontCode: typography.fontFamily.mono.join(', '),
});

3. テーマの適用

カスタムテーマを Storybook に適用します。

typescript// .storybook/preview.tsx
import { Preview } from '@storybook/react';
import { customTheme } from './custom-theme';

const preview: Preview = {
  parameters: {
    docs: {
      theme: customTheme,
    },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
};

export default preview;

ダークモード・ライトモードの切り替え

ユーザーの好みに応じてテーマを切り替えられるようにしましょう。

1. ダークテーマの作成

typescript// .storybook/dark-theme.ts
import { create } from '@storybook/theming/create';
import { colors, typography } from './design-tokens';

export const darkTheme = create({
  base: 'dark',

  // ブランド情報(ライトテーマと同じ)
  brandTitle: 'My Company Design System',
  brandUrl: 'https://mycompany.com',
  brandImage: '/logo-white.svg',

  // ダークモード用カラーパレット
  colorPrimary: colors.primary[400],
  colorSecondary: colors.primary[300],

  // ダークモード用UI要素
  appBg: colors.gray[900],
  appContentBg: colors.gray[800],
  barBg: colors.gray[800],
  barTextColor: colors.gray[100],
  barSelectedColor: colors.primary[400],

  // ダークモード用テキスト
  textColor: colors.gray[100],
  textInverseColor: colors.gray[900],

  // ダークモード用フォーム要素
  inputBg: colors.gray[700],
  inputBorder: colors.gray[600],
  inputTextColor: colors.gray[100],

  // フォント(ライトテーマと同じ)
  fontBase: typography.fontFamily.sans.join(', '),
  fontCode: typography.fontFamily.mono.join(', '),
});

2. テーマ切り替え機能の実装

typescript// .storybook/ThemeToggle.tsx
import React, { useState } from 'react';
import { useGlobals } from '@storybook/manager-api';

export const ThemeToggle = () => {
  const [globals, updateGlobals] = useGlobals();
  const [isDark, setIsDark] = useState(
    globals.theme === 'dark'
  );

  const toggleTheme = () => {
    const newTheme = isDark ? 'light' : 'dark';
    setIsDark(!isDark);
    updateGlobals({ theme: newTheme });
  };

  return (
    <button
      onClick={toggleTheme}
      style={{
        padding: '8px 16px',
        border: '1px solid #ccc',
        borderRadius: '4px',
        background: isDark ? '#333' : '#fff',
        color: isDark ? '#fff' : '#333',
        cursor: 'pointer',
      }}
    >
      {isDark ? '🌞 Light' : '🌙 Dark'}
    </button>
  );
};

3. 動的テーマ切り替えの実装

typescript// .storybook/preview.tsx
import { Preview } from '@storybook/react';
import { customTheme } from './custom-theme';
import { darkTheme } from './dark-theme';

const preview: Preview = {
  globalTypes: {
    theme: {
      description: 'Global theme for components',
      defaultValue: 'light',
      toolbar: {
        title: 'Theme',
        icon: 'circlehollow',
        items: ['light', 'dark'],
        dynamicTitle: true,
      },
    },
  },

  parameters: {
    docs: {
      theme: customTheme, // デフォルトはライトテーマ
    },
  },

  decorators: [
    (Story, context) => {
      const theme =
        context.globals.theme === 'dark'
          ? darkTheme
          : customTheme;

      return (
        <div
          style={{
            background: theme.appBg,
            color: theme.textColor,
            padding: '20px',
            minHeight: '100vh',
          }}
        >
          <Story />
        </div>
      );
    },
  ],
};

export default preview;

ブランドカラーの統合方法

企業のブランドカラーを Storybook に統合する方法を解説します。

1. ブランドカラーパレットの定義

typescript// .storybook/brand-colors.ts
export const brandColors = {
  // メインブランドカラー
  primary: {
    main: '#1a73e8', // Google Blue
    light: '#4285f4',
    dark: '#0d47a1',
    contrast: '#ffffff',
  },

  // セカンダリブランドカラー
  secondary: {
    main: '#34a853', // Google Green
    light: '#66bb6a',
    dark: '#2e7d32',
    contrast: '#ffffff',
  },

  // アクセントカラー
  accent: {
    main: '#ea4335', // Google Red
    light: '#ef5350',
    dark: '#c62828',
    contrast: '#ffffff',
  },

  // ニュートラルカラー
  neutral: {
    50: '#fafafa',
    100: '#f5f5f5',
    200: '#eeeeee',
    300: '#e0e0e0',
    400: '#bdbdbd',
    500: '#9e9e9e',
    600: '#757575',
    700: '#616161',
    800: '#424242',
    900: '#212121',
  },
};

2. ブランドテーマの作成

typescript// .storybook/brand-theme.ts
import { create } from '@storybook/theming/create';
import { brandColors } from './brand-colors';

export const brandTheme = create({
  base: 'light',

  // ブランド情報
  brandTitle: 'Google Design System',
  brandUrl: 'https://design.google',
  brandImage: '/google-logo.svg',

  // ブランドカラーの適用
  colorPrimary: brandColors.primary.main,
  colorSecondary: brandColors.secondary.main,

  // UI要素にブランドカラーを適用
  appBg: brandColors.neutral[50],
  appContentBg: '#ffffff',
  barBg: '#ffffff',
  barTextColor: brandColors.neutral[900],
  barSelectedColor: brandColors.primary.main,

  // テキストカラー
  textColor: brandColors.neutral[900],
  textInverseColor: '#ffffff',

  // フォーム要素
  inputBg: '#ffffff',
  inputBorder: brandColors.neutral[300],
  inputTextColor: brandColors.neutral[900],

  // フォント
  fontBase: '"Google Sans", "Roboto", sans-serif',
  fontCode: '"JetBrains Mono", monospace',
});

3. CSS 変数との連携

ブランドカラーを CSS 変数として定義し、コンポーネントで使用できるようにします。

css/* .storybook/preview.css */
:root {
  /* ブランドカラー */
  --color-primary: #1a73e8;
  --color-primary-light: #4285f4;
  --color-primary-dark: #0d47a1;

  --color-secondary: #34a853;
  --color-secondary-light: #66bb6a;
  --color-secondary-dark: #2e7d32;

  --color-accent: #ea4335;
  --color-accent-light: #ef5350;
  --color-accent-dark: #c62828;

  /* ニュートラルカラー */
  --color-neutral-50: #fafafa;
  --color-neutral-100: #f5f5f5;
  --color-neutral-500: #9e9e9e;
  --color-neutral-900: #212121;

  /* スペーシング */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;

  /* タイポグラフィ */
  --font-family-sans: 'Google Sans', 'Roboto', sans-serif;
  --font-family-mono: 'JetBrains Mono', monospace;
}

フォントとタイポグラフィのカスタマイズ

読みやすく美しいタイポグラフィを実現しましょう。

1. フォントファミリーの設定

typescript// .storybook/typography.ts
export const typographyConfig = {
  fontFamily: {
    sans: [
      'Inter',
      '-apple-system',
      'BlinkMacSystemFont',
      'Segoe UI',
      'Roboto',
      'sans-serif',
    ],
    serif: ['Georgia', 'Times New Roman', 'serif'],
    mono: [
      'JetBrains Mono',
      'Fira Code',
      'Consolas',
      'monospace',
    ],
  },

  fontSize: {
    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
  },

  fontWeight: {
    light: 300,
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700,
    extrabold: 800,
  },

  lineHeight: {
    tight: 1.25,
    normal: 1.5,
    relaxed: 1.75,
  },
};

2. タイポグラフィテーマの適用

typescript// .storybook/typography-theme.ts
import { create } from '@storybook/theming/create';
import { typographyConfig } from './typography';

export const typographyTheme = create({
  base: 'light',

  // フォントファミリー
  fontBase: typographyConfig.fontFamily.sans.join(', '),
  fontCode: typographyConfig.fontFamily.mono.join(', '),

  // その他のテーマ設定
  colorPrimary: '#1a73e8',
  colorSecondary: '#34a853',
  appBg: '#ffffff',
  appContentBg: '#ffffff',
  barBg: '#f8f9fa',
  barTextColor: '#333333',
  textColor: '#333333',
  textInverseColor: '#ffffff',
});

3. フォントの読み込み

Google Fonts やその他の Web フォントを読み込みます。

html<!-- .storybook/preview-head.html -->
<link
  rel="preconnect"
  href="https://fonts.googleapis.com"
/>
<link
  rel="preconnect"
  href="https://fonts.gstatic.com"
  crossorigin
/>
<link
  href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
  rel="stylesheet"
/>

4. タイポグラフィユーティリティの作成

typescript// .storybook/typography-utils.ts
import { typographyConfig } from './typography';

export const createTypographyStyle = (
  size: keyof typeof typographyConfig.fontSize,
  weight: keyof typeof typographyConfig.fontWeight = 'normal',
  family: keyof typeof typographyConfig.fontFamily = 'sans'
) => ({
  fontFamily:
    typographyConfig.fontFamily[family].join(', '),
  fontSize: typographyConfig.fontSize[size],
  fontWeight: typographyConfig.fontWeight[weight],
  lineHeight: typographyConfig.lineHeight.normal,
});

コンポーネント固有のテーマ設定

特定のコンポーネントにのみ適用されるテーマを設定する方法を解説します。

1. コンポーネントレベルテーマの定義

typescript// .storybook/component-themes.ts
export const buttonThemes = {
  primary: {
    backgroundColor: '#1a73e8',
    color: '#ffffff',
    border: 'none',
    borderRadius: '8px',
    padding: '12px 24px',
    fontSize: '16px',
    fontWeight: 500,
    cursor: 'pointer',
    transition: 'all 0.2s ease',

    '&:hover': {
      backgroundColor: '#1557b0',
      transform: 'translateY(-1px)',
    },

    '&:active': {
      backgroundColor: '#0d47a1',
      transform: 'translateY(0)',
    },
  },

  secondary: {
    backgroundColor: 'transparent',
    color: '#1a73e8',
    border: '2px solid #1a73e8',
    borderRadius: '8px',
    padding: '10px 22px',
    fontSize: '16px',
    fontWeight: 500,
    cursor: 'pointer',
    transition: 'all 0.2s ease',

    '&:hover': {
      backgroundColor: '#1a73e8',
      color: '#ffffff',
    },
  },

  danger: {
    backgroundColor: '#ea4335',
    color: '#ffffff',
    border: 'none',
    borderRadius: '8px',
    padding: '12px 24px',
    fontSize: '16px',
    fontWeight: 500,
    cursor: 'pointer',
    transition: 'all 0.2s ease',

    '&:hover': {
      backgroundColor: '#d32f2f',
    },
  },
};

2. ストーリーレベルでのテーマ適用

typescript// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { buttonThemes } from '../.storybook/component-themes';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
};

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

// プライマリボタンのストーリー
export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary',
  },
  decorators: [
    (Story) => (
      <div style={buttonThemes.primary}>
        <Story />
      </div>
    ),
  ],
};

// セカンダリボタンのストーリー
export const Secondary: Story = {
  args: {
    children: 'Secondary Button',
    variant: 'secondary',
  },
  decorators: [
    (Story) => (
      <div style={buttonThemes.secondary}>
        <Story />
      </div>
    ),
  ],
};

// 危険ボタンのストーリー
export const Danger: Story = {
  args: {
    children: 'Danger Button',
    variant: 'danger',
  },
  decorators: [
    (Story) => (
      <div style={buttonThemes.danger}>
        <Story />
      </div>
    ),
  ],
};

3. テーマプロバイダーを使用したコンポーネントテーマ

typescript// .storybook/ComponentThemeProvider.tsx
import React, { createContext, useContext } from 'react';

interface ComponentTheme {
  button: typeof buttonThemes;
  card: any;
  input: any;
}

const ComponentThemeContext =
  createContext<ComponentTheme | null>(null);

export const ComponentThemeProvider: React.FC<{
  children: React.ReactNode;
  theme: ComponentTheme;
}> = ({ children, theme }) => {
  return (
    <ComponentThemeContext.Provider value={theme}>
      {children}
    </ComponentThemeContext.Provider>
  );
};

export const useComponentTheme = () => {
  const context = useContext(ComponentThemeContext);
  if (!context) {
    throw new Error(
      'useComponentTheme must be used within ComponentThemeProvider'
    );
  }
  return context;
};

テーマの動的切り替え機能

ユーザーがリアルタイムでテーマを切り替えられる機能を実装しましょう。

1. テーマ切り替えコンポーネントの作成

typescript// .storybook/ThemeSwitcher.tsx
import React from 'react';
import { useGlobals } from '@storybook/manager-api';

interface ThemeOption {
  name: string;
  value: string;
  icon: string;
}

const themeOptions: ThemeOption[] = [
  { name: 'Light', value: 'light', icon: '☀️' },
  { name: 'Dark', value: 'dark', icon: '🌙' },
  { name: 'Brand', value: 'brand', icon: '🎨' },
  {
    name: 'High Contrast',
    value: 'high-contrast',
    icon: '🔍',
  },
];

export const ThemeSwitcher: React.FC = () => {
  const [globals, updateGlobals] = useGlobals();
  const currentTheme = globals.theme || 'light';

  const handleThemeChange = (themeValue: string) => {
    updateGlobals({ theme: themeValue });
  };

  return (
    <div
      style={{
        display: 'flex',
        gap: '8px',
        padding: '16px',
        borderBottom: '1px solid #e1e5e9',
      }}
    >
      {themeOptions.map((option) => (
        <button
          key={option.value}
          onClick={() => handleThemeChange(option.value)}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: '4px',
            padding: '8px 12px',
            border: `1px solid ${
              currentTheme === option.value
                ? '#1a73e8'
                : '#e1e5e9'
            }`,
            borderRadius: '6px',
            background:
              currentTheme === option.value
                ? '#1a73e8'
                : '#ffffff',
            color:
              currentTheme === option.value
                ? '#ffffff'
                : '#333333',
            cursor: 'pointer',
            fontSize: '14px',
            transition: 'all 0.2s ease',
          }}
        >
          <span>{option.icon}</span>
          <span>{option.name}</span>
        </button>
      ))}
    </div>
  );
};

2. 動的テーママッピングの実装

typescript// .storybook/dynamic-themes.ts
import { customTheme } from './custom-theme';
import { darkTheme } from './dark-theme';
import { brandTheme } from './brand-theme';

export const themeMap = {
  light: customTheme,
  dark: darkTheme,
  brand: brandTheme,
  'high-contrast': {
    ...customTheme,
    colorPrimary: '#000000',
    colorSecondary: '#ffffff',
    appBg: '#ffffff',
    appContentBg: '#ffffff',
    textColor: '#000000',
    barBg: '#000000',
    barTextColor: '#ffffff',
  },
};

export const getThemeByName = (themeName: string) => {
  return (
    themeMap[themeName as keyof typeof themeMap] ||
    customTheme
  );
};

3. 動的テーマ切り替えの統合

typescript// .storybook/preview.tsx
import { Preview } from '@storybook/react';
import { getThemeByName } from './dynamic-themes';
import { ThemeSwitcher } from './ThemeSwitcher';

const preview: Preview = {
  globalTypes: {
    theme: {
      description: 'Global theme for components',
      defaultValue: 'light',
      toolbar: {
        title: 'Theme',
        icon: 'circlehollow',
        items: ['light', 'dark', 'brand', 'high-contrast'],
        dynamicTitle: true,
      },
    },
  },

  parameters: {
    docs: {
      theme: customTheme,
    },
  },

  decorators: [
    (Story, context) => {
      const themeName = context.globals.theme || 'light';
      const theme = getThemeByName(themeName);

      return (
        <div
          style={{
            background: theme.appBg,
            color: theme.textColor,
            minHeight: '100vh',
            fontFamily: theme.fontBase,
          }}
        >
          <ThemeSwitcher />
          <div style={{ padding: '20px' }}>
            <Story />
          </div>
        </div>
      );
    },
  ],
};

export default preview;

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

テーマカスタマイズでパフォーマンスを落とさないためのベストプラクティスを紹介します。

1. テーマの遅延読み込み

typescript// .storybook/lazy-themes.ts
export const lazyLoadTheme = async (themeName: string) => {
  switch (themeName) {
    case 'dark':
      const { darkTheme } = await import('./dark-theme');
      return darkTheme;
    case 'brand':
      const { brandTheme } = await import('./brand-theme');
      return brandTheme;
    default:
      const { customTheme } = await import(
        './custom-theme'
      );
      return customTheme;
  }
};

2. テーマのメモ化

typescript// .storybook/memoized-theme.ts
import { useMemo } from 'react';
import { create } from '@storybook/theming/create';

export const useMemoizedTheme = (themeConfig: any) => {
  return useMemo(() => {
    return create(themeConfig);
  }, [JSON.stringify(themeConfig)]);
};

3. CSS 変数の最適化

css/* .storybook/optimized-theme.css */
/* テーマ切り替え時のパフォーマンス向上のため、CSS変数を活用 */
.theme-light {
  --color-primary: #1a73e8;
  --color-secondary: #34a853;
  --color-background: #ffffff;
  --color-text: #333333;
}

.theme-dark {
  --color-primary: #4285f4;
  --color-secondary: #66bb6a;
  --color-background: #121212;
  --color-text: #ffffff;
}

/* コンポーネントでCSS変数を使用 */
.component {
  background-color: var(--color-background);
  color: var(--color-text);
  border: 1px solid var(--color-primary);
}

4. テーマ切り替えの最適化

typescript// .storybook/optimized-theme-switcher.tsx
import React, { useCallback, useMemo } from 'react';
import { useGlobals } from '@storybook/manager-api';

export const OptimizedThemeSwitcher: React.FC = () => {
  const [globals, updateGlobals] = useGlobals();

  // テーマ切り替えをメモ化
  const handleThemeChange = useCallback(
    (themeName: string) => {
      updateGlobals({ theme: themeName });
    },
    [updateGlobals]
  );

  // 現在のテーマをメモ化
  const currentTheme = useMemo(() => {
    return globals.theme || 'light';
  }, [globals.theme]);

  return (
    <div style={{ display: 'flex', gap: '8px' }}>
      {['light', 'dark', 'brand'].map((theme) => (
        <button
          key={theme}
          onClick={() => handleThemeChange(theme)}
          style={{
            padding: '8px 16px',
            border: `1px solid ${
              currentTheme === theme ? '#1a73e8' : '#e1e5e9'
            }`,
            borderRadius: '4px',
            background:
              currentTheme === theme
                ? '#1a73e8'
                : '#ffffff',
            color:
              currentTheme === theme
                ? '#ffffff'
                : '#333333',
            cursor: 'pointer',
          }}
        >
          {theme.charAt(0).toUpperCase() + theme.slice(1)}
        </button>
      ))}
    </div>
  );
};

よくあるトラブルと解決策

Storybook のテーマカスタマイズでよく遭遇する問題とその解決策を紹介します。

1. テーマが適用されない問題

エラー: テーマの設定が反映されない

原因: パラメータの設定場所が間違っている

typescript// 間違った例
export const parameters = {
  theme: customTheme, // これは機能しません
};

// 正しい例
export const parameters = {
  docs: {
    theme: customTheme,
  },
};

解決策: 正しいパラメータ構造を使用する

2. フォントが読み込まれない問題

エラー: カスタムフォントが表示されない

原因: フォントの読み込みタイミングの問題

html<!-- .storybook/preview-head.html -->
<!-- フォントのプリロード -->
<link
  rel="preload"
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
  as="style"
/>
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
/>

解決策: フォントをプリロードして読み込みを最適化する

3. テーマ切り替え時のちらつき問題

エラー: テーマ切り替え時に画面がちらつく

原因: テーマ切り替え時の再レンダリング

typescript// .storybook/smooth-theme-switch.tsx
import React, { useState, useEffect } from 'react';

export const SmoothThemeSwitch: React.FC = () => {
  const [isTransitioning, setIsTransitioning] =
    useState(false);

  const handleThemeChange = (newTheme: string) => {
    setIsTransitioning(true);

    // トランジション用のCSSクラスを追加
    document.body.style.transition = 'all 0.3s ease';

    setTimeout(() => {
      // テーマを切り替え
      updateGlobals({ theme: newTheme });

      setTimeout(() => {
        setIsTransitioning(false);
        document.body.style.transition = '';
      }, 300);
    }, 50);
  };

  return (
    <div
      className={
        isTransitioning ? 'theme-transitioning' : ''
      }
    >
      {/* テーマ切り替えUI */}
    </div>
  );
};

解決策: CSS トランジションを使用してスムーズな切り替えを実現

4. パフォーマンスの問題

エラー: テーマ切り替えが遅い

原因: 不要な再計算や再レンダリング

typescript// .storybook/performance-optimized-theme.ts
import { useMemo, useCallback } from 'react';

export const useOptimizedTheme = (themeConfig: any) => {
  // テーマ設定をメモ化
  const memoizedConfig = useMemo(
    () => themeConfig,
    [JSON.stringify(themeConfig)]
  );

  // テーマ作成をメモ化
  const theme = useMemo(() => {
    return create(memoizedConfig);
  }, [memoizedConfig]);

  // テーマ切り替えをコールバック化
  const switchTheme = useCallback((newConfig: any) => {
    // テーマ切り替えロジック
  }, []);

  return { theme, switchTheme };
};

解決策: React.memo、useMemo、useCallback を活用してパフォーマンスを最適化

5. TypeScript エラーの解決

エラー: テーマの型定義エラー

typescript// .storybook/theme-types.ts
import { Theme } from '@storybook/theming';

export interface CustomTheme extends Theme {
  // カスタムプロパティの型定義
  customProperty?: string;
  brandColors?: {
    primary: string;
    secondary: string;
  };
}

// 型安全なテーマ作成
export const createTypedTheme = (
  config: Partial<CustomTheme>
): CustomTheme => {
  return create(config) as CustomTheme;
};

解決策: 適切な型定義を作成して型安全性を確保

まとめ

Storybook のテーマカスタマイズは、UI コンポーネントライブラリの品質を大きく左右する重要な要素です。

この記事で紹介した手法を活用することで、以下のような効果が期待できます:

開発効率の向上

  • 一貫したデザインシステムの構築
  • ブランドガイドラインの自動適用
  • チーム開発での統一感の確保

ユーザー体験の向上

  • 美しく統一された UI
  • アクセシビリティへの配慮
  • ダークモード対応

保守性の向上

  • テーマの一元管理
  • 変更の影響範囲の明確化
  • コードの再利用性向上

テーマカスタマイズは、最初は複雑に感じるかもしれませんが、一度設定してしまえば、その後の開発効率が劇的に向上します。

特に、ブランドカラーの統合やダークモード対応は、ユーザーからの評価に直結する重要な要素です。

今回紹介したベストプラクティスを参考に、あなたの Storybook プロジェクトに最適なテーマシステムを構築してください。

そして、テーマカスタマイズを通じて、より魅力的で使いやすい UI コンポーネントライブラリを作り上げていきましょう。

関連リンク