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

現代の Web 開発において、美しい UI を効率的に量産することは、プロダクトの成功を左右する重要な要素です。しかし、多くの開発者が直面する課題があります。
「デザインの一貫性を保ちながら、開発速度を上げるにはどうすればいいのか?」 「チーム全体で統一された UI コンポーネントを管理するには?」 「デザイナーとエンジニアの連携をスムーズにするには?」
これらの課題を解決する鍵が、StorybookとSass/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 を組み合わせることで、以下のような相乗効果が生まれます:
- 開発効率の向上: コンポーネント単体での開発が可能
- デザインの一貫性: 変数やテーマによる統一されたスタイリング
- 保守性の向上: モジュール化されたスタイル管理
- チーム連携の改善: デザイナーとエンジニアの共通言語
実際のプロジェクトでは、これらの組み合わせにより、開発速度が 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 と組み合わせることで、デザインシステムの構築と運用が格段に効率化されます。
デザインシステムの基本概念
デザインシステムは、以下の要素で構成されます:
- デザイントークン: 色、フォント、スペーシングなどの基本値
- コンポーネント: 再利用可能な UI 要素
- パターン: コンポーネントの組み合わせルール
- ドキュメント: 使用方法とガイドライン
デザイントークンの管理
デザイントークンを一元管理することで、一貫したデザインを実現できます。
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 を効率的に量産する方法を実践的に解説してきました。
主要なポイント
- 開発効率の向上: Storybook によるコンポーネント単体開発により、開発速度が 2-3 倍向上
- デザインの一貫性: テーマシステムとデザイントークンによる統一されたスタイリング
- 保守性の向上: モジュール化されたコンポーネントとスタイル管理
- チーム連携の改善: デザイナーとエンジニアの共通言語として機能
実践的なアプローチ
- 段階的な導入: 小さなコンポーネントから始めて、徐々に拡張
- テーマシステム: 複数のテーマに対応し、動的な切り替えを実現
- パフォーマンス最適化: メモ化、遅延読み込み、バンドル最適化
- アクセシビリティ: 包括的なアクセシビリティ対応
今後の発展
この組み合わせを活用することで、以下のような発展が期待できます:
- デザインシステムの構築: 企業全体で統一されたデザインシステム
- コンポーネントライブラリの公開: オープンソースとしての公開
- 自動化の実現: CI/CD パイプラインとの連携
- ドキュメントの自動生成: 常に最新のドキュメント維持
Storybook と Sass/Styled-Components の組み合わせは、現代の Web 開発において、美しい UI を効率的に量産するための最強のツールセットです。この記事で紹介した手法を実践することで、開発チーム全体の生産性と品質を大幅に向上させることができるでしょう。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来