Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング

React アプリケーションでコンポーネントのスタイリングを行う際、ボタンの色やサイズなど、状態や用途に応じてスタイルを切り替える必要がありますよね。 Emotion と TypeScript を組み合わせることで、props から適切なスタイルへ型安全にマッピングする「変種(variants)」パターンを実装できます。 この記事では、Emotion における変種パターンの設計手法と、型安全性を保ちながら保守性の高いコンポーネントを作る方法をご紹介します。
背景
CSS-in-JS ライブラリである Emotion は、JavaScript でスタイルを記述できる強力なツールです。 従来の CSS や CSS Modules と異なり、コンポーネントのロジックとスタイルを同じ場所で管理でき、動的なスタイリングも容易に実現できます。
typescriptimport { css } from '@emotion/react';
// 基本的な Emotion のスタイル定義
const buttonStyle = css`
padding: 8px 16px;
border-radius: 4px;
border: none;
`;
Emotion の基本的な使い方では、上記のように css
関数でスタイルを定義します。
この定義をコンポーネントに適用すると、スタイルが反映されます。
typescriptconst Button = () => {
return <button css={buttonStyle}>クリック</button>;
};
しかし、実際のアプリケーション開発では、単一のスタイルだけでは不十分です。 primary ボタン、secondary ボタン、サイズの大小など、複数の「変種(variants)」を扱う必要があります。
変種パターンにおける主要な要素と関係を図で確認しましょう。
mermaidflowchart TB
props["コンポーネント props"] -->|型で制約| variant["variant 値"]
props -->|型で制約| size["size 値"]
variant -->|マッピング| colorStyle["色スタイル"]
size -->|マッピング| sizeStyle["サイズスタイル"]
colorStyle -->|結合| finalStyle["最終スタイル"]
sizeStyle -->|結合| finalStyle
finalStyle -->|適用| component["レンダリング"]
この図が示すように、props の値が型システムによって制約され、適切なスタイルへマッピングされていきます。
課題
従来の方法で変種を実装すると、いくつかの課題が発生します。
型安全性の欠如
条件分岐で props を判定してスタイルを切り替える素朴な実装では、TypeScript の型チェックが十分に働きません。
typescript// ❌ 型安全でない実装例
const Button = ({ variant, children }: any) => {
let style;
// variant の値が文字列リテラルで制約されていない
if (variant === 'primary') {
style = primaryStyle;
} else if (variant === 'secondary') {
style = secondaryStyle;
}
return <button css={style}>{children}</button>;
};
上記のコードでは、variant
に任意の文字列が渡される可能性があり、タイポや存在しない値を指定してもコンパイルエラーになりません。
typescript// これらはコンパイルエラーにならない
<Button variant="primari">送信</Button> // タイポ
<Button variant="danger">削除</Button> // 未定義の variant
保守性の低下
スタイルの定義が増えるたびに条件分岐を追加する必要があり、コードが肥大化します。
typescript// ❌ 条件分岐が増えて保守性が低い
const Button = ({ variant, size, disabled }: any) => {
let style = baseStyle;
if (variant === 'primary') {
style = css([style, primaryStyle]);
} else if (variant === 'secondary') {
style = css([style, secondaryStyle]);
} else if (variant === 'danger') {
style = css([style, dangerStyle]);
}
if (size === 'small') {
style = css([style, smallStyle]);
} else if (size === 'medium') {
style = css([style, mediumStyle]);
} else if (size === 'large') {
style = css([style, largeStyle]);
}
if (disabled) {
style = css([style, disabledStyle]);
}
return <button css={style}>{children}</button>;
};
新しい variant や size を追加するたびに、条件分岐を追加する必要があります。 このような実装は可読性が低く、バグの温床になりやすいでしょう。
課題をまとめると以下のような構造になります。
mermaidflowchart LR
issue1["型安全性の欠如"] -->|結果| bug1["タイポ・不正値"]
issue2["条件分岐の増加"] -->|結果| bug2["保守性低下"]
issue3["スタイル結合の複雑化"] -->|結果| bug3["可読性低下"]
bug1 --> problem["実装上の問題"]
bug2 --> problem
bug3 --> problem
スタイル結合の複雑化
複数の variant を組み合わせる場合、スタイルの優先順位や上書きルールが複雑になります。
解決策
変種パターンでは、props の値をキーとしたオブジェクトマッピングを使い、型安全にスタイルを選択します。
型定義とスタイルマップの作成
まず、variant として許可する値を TypeScript の Union 型で定義します。
typescript// variant の型定義
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';
この型定義により、指定できる値が制限され、タイポや不正な値を防げます。
次に、各 variant に対応するスタイルをオブジェクトとして定義します。
typescriptimport { css, SerializedStyles } from '@emotion/react';
// variant ごとのスタイルマップ
const variantStyles: Record<
ButtonVariant,
SerializedStyles
> = {
primary: css`
background-color: #007bff;
color: white;
&:hover {
background-color: #0056b3;
}
`,
secondary: css`
background-color: #6c757d;
color: white;
&:hover {
background-color: #545b62;
}
`,
danger: css`
background-color: #dc3545;
color: white;
&:hover {
background-color: #bd2130;
}
`,
};
Record<ButtonVariant, SerializedStyles>
型により、すべての variant に対してスタイルが定義されていることが保証されます。
同様に、サイズのスタイルマップも作成しましょう。
typescript// size ごとのスタイルマップ
const sizeStyles: Record<ButtonSize, SerializedStyles> = {
small: css`
padding: 4px 8px;
font-size: 12px;
`,
medium: css`
padding: 8px 16px;
font-size: 14px;
`,
large: css`
padding: 12px 24px;
font-size: 16px;
`,
};
コンポーネントへの適用
型定義とスタイルマップを使って、型安全なコンポーネントを実装します。
typescriptimport React from 'react';
// Props の型定義
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
onClick?: () => void;
}
Props の型を明示的に定義することで、コンポーネント利用時の型チェックが働きます。
typescript// コンポーネント実装
const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
children,
onClick,
}) => {
// スタイルマップから適切なスタイルを取得
const variantStyle = variantStyles[variant];
const sizeStyle = sizeStyles[size];
return (
<button
css={[baseStyle, variantStyle, sizeStyle]}
onClick={onClick}
>
{children}
</button>
);
};
この実装では、条件分岐を使わずオブジェクトのキーアクセスでスタイルを取得できます。
variantStyles[variant]
という記述により、TypeScript は variant が正しい値であることを保証してくれます。
typescript// 基本スタイル
const baseStyle = css`
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
解決策の全体像を図で確認しましょう。
mermaidflowchart TB
type["Union 型定義"] -->|制約| props["Props 型"]
styleMap["スタイルマップ<br/>Record 型"] -->|型安全| lookup["キーアクセス"]
props -->|渡される| component["コンポーネント"]
component -->|参照| lookup
lookup -->|取得| styles["スタイル"]
styles -->|結合| render["レンダリング"]
図で理解できる要点:
- Union 型が props を制約し、不正な値を防ぐ
- Record 型のスタイルマップがすべての variant に対応
- キーアクセスにより条件分岐なしでスタイル取得
具体例
実際のプロジェクトで使える完全な実装例をご紹介します。
完全な Button コンポーネント
以下は、variant、size、disabled 状態を持つ Button コンポーネントの完全な実装です。
typescript/** @jsxImportSource @emotion/react */
import { css, SerializedStyles } from '@emotion/react';
import React from 'react';
// 型定義
type ButtonVariant =
| 'primary'
| 'secondary'
| 'danger'
| 'success';
type ButtonSize = 'small' | 'medium' | 'large';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
型定義を先頭にまとめることで、コンポーネントの仕様が一目でわかります。
typescript// 基本スタイル
const baseStyle = css`
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
outline: none;
&:focus {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
typescript// variant スタイルマップ
const variantStyles: Record<
ButtonVariant,
SerializedStyles
> = {
primary: css`
background-color: #007bff;
color: white;
&:hover:not(:disabled) {
background-color: #0056b3;
}
`,
secondary: css`
background-color: #6c757d;
color: white;
&:hover:not(:disabled) {
background-color: #545b62;
}
`,
danger: css`
background-color: #dc3545;
color: white;
&:hover:not(:disabled) {
background-color: #bd2130;
}
`,
success: css`
background-color: #28a745;
color: white;
&:hover:not(:disabled) {
background-color: #1e7e34;
}
`,
};
各 variant のスタイルには、hover 状態や disabled 時の挙動も含めて定義します。
typescript// size スタイルマップ
const sizeStyles: Record<ButtonSize, SerializedStyles> = {
small: css`
padding: 4px 12px;
font-size: 12px;
min-width: 60px;
`,
medium: css`
padding: 8px 16px;
font-size: 14px;
min-width: 80px;
`,
large: css`
padding: 12px 24px;
font-size: 16px;
min-width: 100px;
`,
};
typescript// Button コンポーネント
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
disabled = false,
children,
onClick,
}) => {
// マップからスタイルを取得
const variantStyle = variantStyles[variant];
const sizeStyle = sizeStyles[size];
return (
<button
css={[baseStyle, variantStyle, sizeStyle]}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
};
このコンポーネントは、props の値に基づいて自動的に適切なスタイルが適用されます。
使用例
実装した Button コンポーネントの使用例を見てみましょう。
typescriptimport { Button } from './Button';
const App = () => {
return (
<div>
{/* primary ボタン(デフォルト) */}
<Button onClick={() => alert('送信')}>送信</Button>
{/* secondary ボタン、large サイズ */}
<Button variant='secondary' size='large'>
キャンセル
</Button>
{/* danger ボタン、small サイズ */}
<Button variant='danger' size='small'>
削除
</Button>
{/* success ボタン、disabled 状態 */}
<Button variant='success' disabled>
保存済み
</Button>
</div>
);
};
TypeScript により、存在しない variant や size を指定するとコンパイルエラーになります。
typescript// ❌ これらはコンパイルエラーになる
<Button variant="primari">送信</Button> // タイポ
<Button variant="warning">警告</Button> // 未定義の variant
<Button size="extra-large">大</Button> // 未定義の size
使用時のデータフローを図で確認しましょう。
mermaidsequenceDiagram
participant User as 開発者
participant Component as Button コンポーネント
participant TypeCheck as TypeScript
participant StyleMap as スタイルマップ
participant DOM as DOM
User->>Component: props 渡し
Component->>TypeCheck: 型チェック
TypeCheck-->>Component: OK / Error
Component->>StyleMap: variant でキーアクセス
StyleMap-->>Component: スタイル取得
Component->>StyleMap: size でキーアクセス
StyleMap-->>Component: スタイル取得
Component->>DOM: スタイル適用
DOM-->>User: レンダリング
高度な実装:複合的な variant
複数の軸で variant を組み合わせる場合の実装例をご紹介します。
typescript// 複合的な variant の型定義
type ButtonState =
| 'default'
| 'loading'
| 'success'
| 'error';
interface AdvancedButtonProps extends ButtonProps {
state?: ButtonState;
fullWidth?: boolean;
}
state という新しい軸を追加し、ボタンの状態を表現できるようにしました。
typescript// state スタイルマップ
const stateStyles: Record<ButtonState, SerializedStyles> = {
default: css``,
loading: css`
position: relative;
color: transparent;
pointer-events: none;
&::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
top: 50%;
left: 50%;
margin-left: -8px;
margin-top: -8px;
border: 2px solid white;
border-radius: 50%;
border-top-color: transparent;
animation: spinner 0.6s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
`,
success: css`
background-color: #28a745;
pointer-events: none;
`,
error: css`
background-color: #dc3545;
animation: shake 0.5s;
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
`,
};
loading 状態ではスピナーアニメーションを、error 状態では振動アニメーションを表示します。
typescript// フルオプションの Button コンポーネント
export const AdvancedButton: React.FC<
AdvancedButtonProps
> = ({
variant = 'primary',
size = 'medium',
state = 'default',
fullWidth = false,
disabled = false,
children,
onClick,
}) => {
const variantStyle = variantStyles[variant];
const sizeStyle = sizeStyles[size];
const stateStyle = stateStyles[state];
// fullWidth のスタイル
const widthStyle = fullWidth
? css`
width: 100%;
`
: css``;
return (
<button
css={[
baseStyle,
variantStyle,
sizeStyle,
stateStyle,
widthStyle,
]}
disabled={disabled || state === 'loading'}
onClick={onClick}
>
{children}
</button>
);
};
複数のスタイルマップから取得したスタイルを配列で結合することで、柔軟な組み合わせが可能になります。
typescript// 高度な使用例
const FormComponent = () => {
const [submitState, setSubmitState] =
useState<ButtonState>('default');
const handleSubmit = async () => {
setSubmitState('loading');
try {
await submitForm();
setSubmitState('success');
setTimeout(() => setSubmitState('default'), 2000);
} catch (error) {
setSubmitState('error');
setTimeout(() => setSubmitState('default'), 2000);
}
};
return (
<AdvancedButton
variant='primary'
size='large'
state={submitState}
fullWidth
onClick={handleSubmit}
>
{submitState === 'success' ? '送信完了' : '送信'}
</AdvancedButton>
);
};
このように、state を制御することで、非同期処理の状態をビジュアルで表現できます。
テーマ対応の実装
さらに発展させて、テーマ(ライトモード・ダークモード)に対応する方法をご紹介します。
typescriptimport { Theme } from '@emotion/react';
// テーマの型定義
interface AppTheme {
mode: 'light' | 'dark';
colors: {
primary: string;
secondary: string;
danger: string;
success: string;
};
}
typescript// テーマを受け取るスタイルマップ
const createVariantStyles = (
theme: AppTheme
): Record<ButtonVariant, SerializedStyles> => ({
primary: css`
background-color: ${theme.colors.primary};
color: white;
&:hover:not(:disabled) {
filter: brightness(0.9);
}
`,
secondary: css`
background-color: ${theme.colors.secondary};
color: white;
&:hover:not(:disabled) {
filter: brightness(0.9);
}
`,
danger: css`
background-color: ${theme.colors.danger};
color: white;
&:hover:not(:disabled) {
filter: brightness(0.9);
}
`,
success: css`
background-color: ${theme.colors.success};
color: white;
&:hover:not(:disabled) {
filter: brightness(0.9);
}
`,
});
テーマの色を関数で受け取ることで、動的にスタイルを生成できます。
typescriptimport { useTheme } from '@emotion/react';
// テーマ対応 Button
export const ThemedButton: React.FC<ButtonProps> = (
props
) => {
const theme = useTheme() as AppTheme;
const variantStyles = createVariantStyles(theme);
const variantStyle =
variantStyles[props.variant || 'primary'];
const sizeStyle = sizeStyles[props.size || 'medium'];
return (
<button css={[baseStyle, variantStyle, sizeStyle]}>
{props.children}
</button>
);
};
useTheme
フックを使うことで、現在のテーマに応じたスタイルを適用できます。
型安全性の検証
最後に、この実装がどのように型安全性を保証しているか確認しましょう。
# | 検証項目 | 型による保護 | 結果 |
---|---|---|---|
1 | 不正な variant 値 | Union 型で制限 | ★★★ |
2 | スタイルマップの網羅性 | Record 型で保証 | ★★★ |
3 | Props の必須・任意 | interface で明示 | ★★★ |
4 | テーマの型整合性 | テーマ型定義 | ★★★ |
typescript// TypeScript が検出するエラー例
// ❌ 型エラー: 'invalid' は ButtonVariant に存在しない
const button1 = <Button variant='invalid'>ボタン</Button>;
// ❌ 型エラー: variantStyles に 'warning' キーが存在しない
const variantStyles: Record<
ButtonVariant,
SerializedStyles
> = {
primary: css`...`,
secondary: css`...`,
// danger と success が不足している
};
// ❌ 型エラー: children は必須
const button2 = <Button variant='primary' />;
// ✅ 正しい使い方
const button3 = (
<Button variant='primary' size='large'>
送信
</Button>
);
TypeScript のコンパイラが、これらのエラーをすべて検出してくれます。
まとめ
Emotion における変種(variants)パターンの実装方法を解説しました。
この手法により、以下のメリットが得られます:
- 型安全性: Union 型と Record 型により、不正な値や未定義のスタイルを防止
- 保守性: 条件分岐を排除し、スタイルマップとして整理されたコード
- 拡張性: 新しい variant の追加がスタイルマップへのエントリ追加だけで完結
- 可読性: props とスタイルの対応関係が明確で、コードの意図が理解しやすい
- テスタビリティ: 各 variant が独立しており、テストケースの作成が容易
条件分岐による実装と比較して、型システムを活用した変種パターンは、大規模なプロジェクトでも安全に保守できる設計といえるでしょう。
この記事で紹介したパターンは、Button 以外のコンポーネント(Card、Badge、Alert など)にも応用できます。 ぜひプロジェクトで実践してみてください。
関連リンク
- article
Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング
- article
Emotion チートシート:css/styled/Global/Theme の即戦力スニペット 20
- article
Emotion 初期設定完全ガイド:Babel/SWC/型定義/型拡張のベストプラクティス
- article
Motion(旧 Framer Motion) vs CSS Transition/WAAPI:可読性・制御性・性能を実測比較
- article
Emotion vs styled-components vs Stitches 徹底比較:DX/SSR/パフォーマンス実測
- article
Emotion 完全理解 2025:CSS-in-JS の強み・弱み・採用判断を徹底解説
- article
shadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
- article
GPT-5 本番運用の SLO 設計:品質(正確性/再現性)・遅延・コストの三点均衡を保つ
- article
Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング
- article
Remix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装
- article
Preact でミニブログを 1 日で公開:ルーティング・MDX・SEO まで一気通貫
- article
Electron スクリーンレコーダー/キャプチャツールを desktopCapturer で作る
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来