Storybook で Figma デザインをそのまま再現するテクニック

デザイナーが丹精込めて作成した Figma デザインを、Web 上で完璧に再現したいと思ったことはありませんか?しかし実際には、デザインツールと Web 実装の間には大きなギャップが存在し、多くの開発者がその壁に直面しています。
「デザイン通りに作ったつもりなのに、なぜか違和感がある」「微妙なずれが気になるけれど、どう修正すればいいかわからない」そんな悩みを抱えている方も多いのではないでしょうか。
本記事では、Figma デザインを Web 上で正確に再現するための具体的なテクニックを、実装レベルで詳しく解説いたします。CSS の細かな調整方法から Storybook での検証環境構築まで、現場で即座に活用できる実践的な手法をお伝えします。
Figma と Web 実装のギャップ分析
デザインツールと Web の違い
Figma と Web ブラウザでは、レンダリングエンジンや表示の仕組みが根本的に異なります。この違いを理解することが、正確な再現への第一歩となります。
要素 | Figma | Web ブラウザ | 主な課題 |
---|---|---|---|
座標システム | 絶対座標ベース | ボックスモデルベース | レイアウト崩れが発生しやすい |
フォントレンダリング | Figma 独自エンジン | ブラウザ依存 | 文字の見た目に差異が生じる |
シャドウ計算 | ベクター計算 | CSS レンダリング | 影の表現に微妙な違い |
カラープロファイル | sRGB 固定 | ブラウザ・デバイス依存 | 色の見え方が変わる可能性 |
アニメーション | タイムライン方式 | CSS/JS アニメーション | 実装方法が大きく異なる |
実際の開発では、これらの違いにより予期しない表示崩れが発生することがあります。特に精密なレイアウトが求められる場合、1px のずれでも視覚的に大きな影響を与えてしまいます。
よくある再現困難な要素の整理
開発現場でよく遭遇する、再現が困難な要素を難易度別に整理しました。
高難易度(特別な対応が必要)
要素 | 具体的な課題 | 影響度 |
---|---|---|
複雑なクリッピングパス | CSS clip-path の限界 | 高 |
グラデーション付きボーダー | 標準 CSS では実現困難 | 高 |
カスタムドロップシャドウ | filter: drop-shadow の制約 | 中 |
可変幅テキストの中央配置 | フォントメトリクスの違い | 高 |
オーバーフロー時の省略表示 | 行数制限との組み合わせ | 中 |
中難易度(工夫で解決可能)
要素 | 具体的な課題 | 推奨解決策 |
---|---|---|
Auto Layout の入れ子 | CSS Grid + Flexbox の組み合わせ | 段階的な実装 |
動的サイズ変更 | レスポンシブ対応 | CSS Variables 活用 |
ホバーエフェクト | インタラクション状態 | CSS Transitions |
アスペクト比維持 | 画像・動画の表示 | aspect-ratio プロパティ |
これらの要素について、次章から具体的な解決テクニックをご紹介していきます。
精密再現のための 8 つのテクニック
1. ピクセルパーフェクトな寸法再現術
Figma で指定された寸法を正確に Web で再現するには、ブラウザの box-sizing や単位の扱いを理解する必要があります。
基本的な寸法設定
css/* 全要素で box-sizing を統一 */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Figma の寸法をそのまま適用 */
.figma-component {
width: 320px;
height: 240px;
padding: 16px 24px;
margin: 0;
}
レスポンシブ対応での寸法管理
css/* CSS Variables でサイズ管理 */
:root {
--component-width-mobile: 280px;
--component-width-tablet: 320px;
--component-width-desktop: 360px;
--component-padding: 16px;
}
.responsive-component {
width: var(--component-width-mobile);
padding: var(--component-padding);
}
@media (min-width: 768px) {
.responsive-component {
width: var(--component-width-tablet);
}
}
@media (min-width: 1024px) {
.responsive-component {
width: var(--component-width-desktop);
}
}
よくあるエラーと解決策
typescript// ❌ よくある間違い:border-box を考慮していない
const incorrectStyle = {
width: '320px',
padding: '20px',
border: '2px solid #ccc',
// 実際の表示幅は 320px + 40px (padding) + 4px (border) = 364px
};
// ✅ 正しい実装:box-sizing を考慮
const correctStyle = {
width: '320px',
padding: '20px',
border: '2px solid #ccc',
boxSizing: 'border-box',
// 実際の表示幅は 320px(padding と border 込み)
};
2. Figma の Auto Layout を CSS Grid/Flexbox で再現
Figma の Auto Layout は強力な機能ですが、CSS での実装には工夫が必要です。
基本的な Auto Layout の再現
css/* Figma: Auto Layout (Vertical, Packed, 16px gap) */
.auto-layout-vertical {
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-start; /* Figma の Left 配置 */
}
/* Figma: Auto Layout (Horizontal, Space Between) */
.auto-layout-horizontal {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12px;
}
複雑な入れ子 Auto Layout の実装
typescript// React コンポーネントでの実装例
interface AutoLayoutProps {
direction: 'horizontal' | 'vertical';
spacing: number;
padding?: number;
alignment?: 'start' | 'center' | 'end' | 'space-between';
children: React.ReactNode;
}
const AutoLayout: React.FC<AutoLayoutProps> = ({
direction,
spacing,
padding = 0,
alignment = 'start',
children,
}) => {
return (
<div
style={{
display: 'flex',
flexDirection:
direction === 'horizontal' ? 'row' : 'column',
gap: `${spacing}px`,
padding: `${padding}px`,
justifyContent:
alignment === 'space-between'
? 'space-between'
: alignment === 'center'
? 'center'
: alignment === 'end'
? 'flex-end'
: 'flex-start',
alignItems:
direction === 'horizontal' ? 'center' : 'stretch',
}}
>
{children}
</div>
);
};
実際の使用例
typescript// Storybook ストーリーでの活用
export const FigmaAutoLayoutReplication: Story = {
render: () => (
<AutoLayout
direction='vertical'
spacing={24}
padding={32}
>
<AutoLayout
direction='horizontal'
spacing={16}
alignment='space-between'
>
<Button variant='primary'>保存</Button>
<Button variant='secondary'>キャンセル</Button>
</AutoLayout>
<AutoLayout direction='vertical' spacing={8}>
<Text>タイトル</Text>
<Text size='small' color='gray'>
説明文がここに入ります
</Text>
</AutoLayout>
</AutoLayout>
),
};
3. 複雑なシャドウ・グラデーションの完全再現
Figma のビジュアルエフェクトを CSS で正確に再現するのは、最も技術的に困難な部分の一つです。
複数レイヤーのドロップシャドウ
css/* Figma: 複数のドロップシャドウを重ねた場合 */
.complex-shadow {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), /* 近距離の影 */
0 1px 2px rgba(0, 0, 0, 0.24),
/* 中距離の影 */ 0 4px 8px rgba(0, 0, 0, 0.12), /* 遠距離の影 */
0 8px 16px rgba(0, 0, 0, 0.08); /* 最遠距離の影 */
}
/* より細かな調整が必要な場合 */
.premium-shadow {
box-shadow: 0 0.5px 1px rgba(0, 0, 0, 0.11), 0 1.5px 3px
rgba(0, 0, 0, 0.13), 0 3px 6px rgba(0, 0, 0, 0.15), 0
6px 12px rgba(0, 0, 0, 0.17),
0 12px 24px rgba(0, 0, 0, 0.19);
}
グラデーション付きボーダーの実装
css/* Figma のグラデーションボーダーを再現 */
.gradient-border {
position: relative;
background: white;
border-radius: 8px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
padding: 2px; /* ボーダーの太さ */
background: linear-gradient(
135deg,
#667eea 0%,
#764ba2 100%
);
border-radius: inherit;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(
#fff 0 0
);
mask-composite: exclude;
-webkit-mask-composite: destination-out;
}
グラデーション背景の正確な再現
css/* Figma: 角度付きグラデーション */
.figma-gradient {
background: linear-gradient(
135deg,
#667eea 0%,
#764ba2 100%
);
}
/* 放射状グラデーション */
.radial-gradient {
background: radial-gradient(
circle at 30% 70%,
#667eea 0%,
#764ba2 50%,
#f093fb 100%
);
}
/* 複数色のグラデーション */
.multi-color-gradient {
background: linear-gradient(
90deg,
#ff6b6b 0%,
#4ecdc4 25%,
#45b7d1 50%,
#96ceb4 75%,
#feca57 100%
);
}
4. カスタムフォント・文字詰めの正確な実装
フォントの表示は、Figma と Web で最も差が出やすい部分です。
カスタムフォントの読み込み
css/* Web フォントの最適化された読み込み */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2'), url('/fonts/custom-font.woff')
format('woff');
font-display: swap;
font-weight: normal;
font-style: normal;
}
/* フォールバックフォントの指定 */
.custom-text {
font-family: 'CustomFont', 'Helvetica Neue', Helvetica,
Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
letter-spacing: -0.01em; /* 文字詰め */
}
文字詰め・行間の調整
css/* Figma の文字詰め設定を再現 */
.tight-spacing {
letter-spacing: -0.02em;
word-spacing: -0.05em;
}
.loose-spacing {
letter-spacing: 0.05em;
word-spacing: 0.1em;
}
/* 行間の調整 */
.custom-line-height {
line-height: 1.4; /* Figma の line-height 設定に合わせる */
font-size: 18px;
}
テキストオーバーフローの処理
css/* 1行省略 */
.single-line-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 複数行省略(WebKit のみ) */
.multi-line-ellipsis {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* 全ブラウザ対応の複数行省略 */
.multi-line-ellipsis-fallback {
position: relative;
max-height: 4.5em; /* line-height × 行数 */
overflow: hidden;
}
.multi-line-ellipsis-fallback::after {
content: '...';
position: absolute;
bottom: 0;
right: 0;
background: white;
padding-left: 20px;
}
5. レスポンシブ対応での一貫性保持
Figma のデザインを異なるデバイスサイズで一貫して表示するには、適切なレスポンシブ戦略が必要です。
ブレークポイントの統一
css/* Figma のフレームサイズに基づくブレークポイント */
:root {
--breakpoint-mobile: 375px;
--breakpoint-tablet: 768px;
--breakpoint-desktop: 1200px;
--breakpoint-wide: 1440px;
}
/* メディアクエリの標準化 */
@media (min-width: 375px) {
/* Mobile */
}
@media (min-width: 768px) {
/* Tablet */
}
@media (min-width: 1200px) {
/* Desktop */
}
@media (min-width: 1440px) {
/* Wide */
}
コンテナクエリの活用
css/* モダンブラウザでのコンテナクエリ */
.responsive-component {
container-type: inline-size;
}
@container (min-width: 300px) {
.responsive-component .content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
}
@container (min-width: 500px) {
.responsive-component .content {
grid-template-columns: 1fr 1fr 1fr;
}
}
動的なスペーシング
css/* clamp() を使った動的サイズ調整 */
.dynamic-spacing {
padding: clamp(16px, 4vw, 48px);
margin-bottom: clamp(24px, 6vw, 64px);
font-size: clamp(14px, 2.5vw, 18px);
}
/* CSS Variables でのレスポンシブ制御 */
:root {
--spacing-unit: 8px;
}
@media (min-width: 768px) {
:root {
--spacing-unit: 12px;
}
}
@media (min-width: 1200px) {
:root {
--spacing-unit: 16px;
}
}
.component {
padding: calc(var(--spacing-unit) * 2);
margin: var(--spacing-unit);
}
よくあるレスポンシブエラー
typescript// ❌ よくある間違い:固定値でのレスポンシブ対応
const incorrectResponsive = {
width: '320px', // モバイルでは画面からはみ出す可能性
'@media (min-width: 768px)': {
width: '400px',
},
};
// ✅ 正しい実装:相対値とmax-widthの活用
const correctResponsive = {
width: '100%',
maxWidth: '320px',
margin: '0 auto',
'@media (min-width: 768px)': {
maxWidth: '400px',
},
};
6. インタラクション・アニメーションの再現
Figma のプロトタイプで定義されたインタラクションを CSS/JavaScript で実装します。
基本的なホバーエフェクト
css/* Figma のホバー状態を再現 */
.interactive-button {
background: #007bff;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
cursor: pointer;
transition: all 0.2s ease-in-out;
transform: translateY(0);
}
.interactive-button:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
}
.interactive-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
}
.interactive-button:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
複雑なアニメーション
css/* キーフレームアニメーション */
@keyframes slideInFromLeft {
0% {
opacity: 0;
transform: translateX(-100px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.animated-element {
animation: slideInFromLeft 0.6s ease-out;
}
/* ステージングアニメーション */
.staggered-list li {
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.5s ease-out forwards;
}
.staggered-list li:nth-child(1) {
animation-delay: 0.1s;
}
.staggered-list li:nth-child(2) {
animation-delay: 0.2s;
}
.staggered-list li:nth-child(3) {
animation-delay: 0.3s;
}
.staggered-list li:nth-child(4) {
animation-delay: 0.4s;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
React でのインタラクション実装
typescript// フェードイン・アウトコンポーネント
import { useState, useEffect } from 'react';
interface FadeTransitionProps {
show: boolean;
children: React.ReactNode;
duration?: number;
}
const FadeTransition: React.FC<FadeTransitionProps> = ({
show,
children,
duration = 300,
}) => {
const [shouldRender, setShouldRender] = useState(show);
useEffect(() => {
if (show) setShouldRender(true);
}, [show]);
const onAnimationEnd = () => {
if (!show) setShouldRender(false);
};
return shouldRender ? (
<div
style={{
transition: `opacity ${duration}ms ease-in-out`,
opacity: show ? 1 : 0,
}}
onTransitionEnd={onAnimationEnd}
>
{children}
</div>
) : null;
};
// マイクロインタラクションの実装
const MicroInteractionButton: React.FC = () => {
const [isPressed, setIsPressed] = useState(false);
return (
<button
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
style={{
transform: isPressed ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease-in-out',
}}
>
クリックしてください
</button>
);
};
7. コンポーネント状態の網羅的実装
Figma で定義された全ての状態を確実に実装するための戦略です。
状態管理の体系化
typescript// コンポーネント状態の型定義
type ButtonState =
| 'default'
| 'hover'
| 'active'
| 'disabled'
| 'loading';
type ButtonVariant =
| 'primary'
| 'secondary'
| 'tertiary'
| 'danger';
type ButtonSize = 'small' | 'medium' | 'large';
interface ButtonProps {
state?: ButtonState;
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
}
// 状態に基づくスタイル定義
const getButtonStyles = (
state: ButtonState,
variant: ButtonVariant,
size: ButtonSize
) => {
const baseStyles = {
borderRadius: '8px',
border: 'none',
cursor:
state === 'disabled' ? 'not-allowed' : 'pointer',
opacity: state === 'disabled' ? 0.5 : 1,
transition: 'all 0.2s ease-in-out',
position: 'relative' as const,
};
const variantStyles = {
primary: { background: '#007bff', color: 'white' },
secondary: { background: '#6c757d', color: 'white' },
tertiary: {
background: 'transparent',
color: '#007bff',
border: '1px solid #007bff',
},
danger: { background: '#dc3545', color: 'white' },
};
const sizeStyles = {
small: { padding: '8px 16px', fontSize: '14px' },
medium: { padding: '12px 24px', fontSize: '16px' },
large: { padding: '16px 32px', fontSize: '18px' },
};
return {
...baseStyles,
...variantStyles[variant],
...sizeStyles[size],
};
};
// ローディング状態の実装
const LoadingSpinner: React.FC<{ size?: number }> = ({
size = 16,
}) => (
<div
style={{
width: size,
height: size,
border: '2px solid transparent',
borderTop: '2px solid currentColor',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
);
// CSS in JS でのスピンアニメーション
const spinKeyframes = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
エラー状態の実装
typescript// フォーム入力のエラー状態
interface InputProps {
value: string;
error?: string;
isValid?: boolean;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
const Input: React.FC<InputProps> = ({
value,
error,
isValid = true,
onChange,
placeholder,
disabled = false,
}) => {
const [isFocused, setIsFocused] = useState(false);
const getBorderColor = () => {
if (error) return '#dc3545';
if (isFocused) return '#007bff';
if (isValid && value) return '#28a745';
return '#ced4da';
};
return (
<div className='input-container'>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
disabled={disabled}
style={{
border: `2px solid ${getBorderColor()}`,
borderRadius: '4px',
padding: '12px',
fontSize: '16px',
transition: 'border-color 0.2s ease-in-out',
backgroundColor: disabled ? '#f8f9fa' : 'white',
cursor: disabled ? 'not-allowed' : 'text',
}}
/>
{error && (
<div
style={{
color: '#dc3545',
fontSize: '14px',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span>⚠️</span>
{error}
</div>
)}
{isValid && value && !error && (
<div
style={{
color: '#28a745',
fontSize: '14px',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span>✅</span>
入力内容に問題ありません
</div>
)}
</div>
);
};
8. 画像・アイコンの最適化配置
Figma の画像やアイコンを Web で効率的に表示するための手法です。
レスポンシブ画像の実装
typescript// Next.js の Image コンポーネントを活用
import Image from 'next/image';
interface ResponsiveImageProps {
src: string;
alt: string;
aspectRatio?: number;
sizes?: string;
priority?: boolean;
}
const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
alt,
aspectRatio = 16 / 9,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
priority = false,
}) => {
return (
<div style={{ position: 'relative', aspectRatio }}>
<Image
src={src}
alt={alt}
fill
sizes={sizes}
style={{ objectFit: 'cover' }}
placeholder='blur'
blurDataURL='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWEREiMxUf/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=='
priority={priority}
/>
</div>
);
};
// アート方向の制御
const ArtDirectedImage: React.FC = () => (
<picture>
<source
media='(max-width: 768px)'
srcSet='/images/hero-mobile.webp'
/>
<source
media='(max-width: 1200px)'
srcSet='/images/hero-tablet.webp'
/>
<img
src='/images/hero-desktop.webp'
alt='Hero image'
style={{ width: '100%', height: 'auto' }}
/>
</picture>
);
SVG アイコンの最適化
typescript// アイコンコンポーネントの標準化
interface IconProps {
name: string;
size?: number;
color?: string;
className?: string;
'aria-label'?: string;
}
const Icon: React.FC<IconProps> = ({
name,
size = 24,
color = 'currentColor',
className,
'aria-label': ariaLabel,
}) => {
return (
<svg
width={size}
height={size}
className={className}
style={{ color }}
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
role={ariaLabel ? 'img' : undefined}
>
<use href={`/icons/sprite.svg#${name}`} />
</svg>
);
};
// インラインSVGでの詳細制御
const CustomIcon: React.FC<{
size?: number;
color?: string;
}> = ({ size = 24, color = 'currentColor' }) => (
<svg
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M12 2L15.09 8.26L22 9L17 14.74L18.18 22L12 18.27L5.82 22L7 14.74L2 9L8.91 8.26L12 2Z'
fill={color}
/>
</svg>
);
// 使用例とアクセシビリティ対応
export const IconButton: React.FC = () => (
<button
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
aria-label='次のページに進む'
>
<Icon
name='arrow-right'
size={16}
aria-label='矢印アイコン'
/>
続行
</button>
);
画像の遅延読み込みとプレースホルダー
typescript// カスタム画像コンポーネント
import { useState } from 'react';
interface LazyImageProps {
src: string;
alt: string;
width: number;
height: number;
className?: string;
}
const LazyImage: React.FC<LazyImageProps> = ({
src,
alt,
width,
height,
className,
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const placeholder = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' font-family='Arial' font-size='16' fill='%23999' text-anchor='middle' dy='.3em'%3E読み込み中...%3C/text%3E%3C/svg%3E`;
if (hasError) {
return (
<div
style={{
width,
height,
backgroundColor: '#f8f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #dee2e6',
borderRadius: '4px',
}}
>
<span style={{ color: '#6c757d' }}>
画像を読み込めませんでした
</span>
</div>
);
}
return (
<div style={{ position: 'relative', width, height }}>
{!isLoaded && (
<img
src={placeholder}
alt=''
width={width}
height={height}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
)}
<img
src={src}
alt={alt}
width={width}
height={height}
className={className}
loading='lazy'
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
);
};
Storybook での検証・比較環境構築
Figma デザインとの比較表示
Storybook の基本設定
typescript// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-controls',
'storybook-addon-figma', // Figma 連携アドオン
'@storybook/addon-viewport',
'@storybook/addon-measure',
'@storybook/addon-outline',
'@storybook/addon-a11y',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
Figma 比較ストーリーの作成
typescript// Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
// Figma デザインとの比較表示
design: {
type: 'figma',
url: 'https://www.figma.com/file/abc123/Design-System?node-id=123%3A456',
},
},
argTypes: {
variant: {
control: { type: 'select' },
options: [
'primary',
'secondary',
'tertiary',
'danger',
],
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large'],
},
disabled: {
control: { type: 'boolean' },
},
loading: {
control: { type: 'boolean' },
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// 各状態での Figma 比較
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/abc123/Design-System?node-id=123%3A789',
},
},
};
export const AllStates: Story = {
render: () => (
<div
style={{
display: 'grid',
gridTemplateColumns:
'repeat(auto-fit, minmax(150px, 1fr))',
gap: '16px',
padding: '20px',
}}
>
<Button variant='primary'>Default</Button>
<Button variant='primary' disabled>
Disabled
</Button>
<Button variant='primary' loading>
Loading
</Button>
<Button variant='secondary'>Secondary</Button>
<Button variant='tertiary'>Tertiary</Button>
<Button variant='danger'>Danger</Button>
</div>
),
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/abc123/Design-System?node-id=456%3A789',
},
viewport: {
defaultViewport: 'desktop',
},
},
};
// レスポンシブテスト用ストーリー
export const ResponsiveTest: Story = {
render: () => (
<div style={{ width: '100%', padding: '20px' }}>
<Button
variant='primary'
style={{ width: '100%', maxWidth: '300px' }}
>
レスポンシブボタン
</Button>
</div>
),
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
design: {
type: 'figma',
url: 'https://www.figma.com/file/abc123/Design-System?node-id=789%3A123',
},
},
};
自動ビジュアルテストの設定
Chromatic との連携
typescript// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async postRender(page, context) {
// Figma デザインとの一致度チェック
const elementHandler = await page.$(
'[data-testid="component-root"]'
);
if (elementHandler) {
// コンポーネントのサイズ測定
const boundingBox =
await elementHandler.boundingBox();
console.log(
`Component size: ${boundingBox?.width}x${boundingBox?.height}`
);
// スクリーンショット取得
await page.screenshot({
path: `screenshots/${context.title.replace(
/\//g,
'-'
)}-${context.name}.png`,
clip: boundingBox || undefined,
});
}
// アクセシビリティテスト
const injectAxe = require('axe-playwright');
await injectAxe.injectAxe(page);
try {
await injectAxe.checkA11y(page);
} catch (e) {
console.error('Accessibility violations found:', e);
}
},
// テスト前の準備
async preRender(page) {
// フォントの読み込み完了を待機
await page.evaluateHandle('document.fonts.ready');
// カスタムCSS変数の設定
await page.addStyleTag({
content: `
:root {
--font-family-primary: 'Inter', sans-serif;
--color-primary: #007bff;
--color-secondary: #6c757d;
}
`,
});
},
};
export default config;
ビジュアル回帰テストの設定
javascript// chromatic.config.js
module.exports = {
projectToken: process.env.CHROMATIC_PROJECT_TOKEN,
buildScriptName: 'build-storybook',
onlyChanged: true, // 変更されたストーリーのみテスト
externals: ['public/**'], // 外部ファイルの変更を無視
delay: 300, // アニメーション完了を待つ
diffThreshold: 0.2, // 差分の閾値設定
pauseAnimationAtEnd: true, // アニメーション終了時点で比較
// Figma との比較で重要なストーリーを優先
storybookBuildDir: 'storybook-static',
skip: 'dependabot/**', // 特定ブランチをスキップ
};
チーム運用での品質担保システム
デザイン実装チェックリスト
開発チームが使用する実装品質チェックリストを標準化します。
# | チェック項目 | 確認方法 | 合格基準 | 責任者 |
---|---|---|---|---|
1 | 寸法の正確性 | Storybook + 開発者ツール | ±2px 以内 | フロントエンドエンジニア |
2 | カラーの一致 | カラーピッカーでの確認 | 完全一致 | フロントエンドエンジニア |
3 | フォントサイズ・行間 | 計算値での比較 | ±0.1em 以内 | フロントエンドエンジニア |
4 | シャドウ・エフェクト | 視覚的確認 | デザイナーの承認 | デザイナー + エンジニア |
5 | レスポンシブ対応 | 複数デバイスでの確認 | 全ブレークポイントで正常表示 | フロントエンドエンジニア |
6 | インタラクション | 手動テスト | 仕様通りの動作 | QA + エンジニア |
7 | アクセシビリティ | 自動 + 手動テスト | WCAG 2.1 AA レベル | アクセシビリティ担当者 |
8 | パフォーマンス | Lighthouse スコア | 90 点以上 | フロントエンドエンジニア |
CI/CD でのビジュアルテスト自動化
yaml# .github/workflows/visual-testing.yml
name: Visual Testing
on:
pull_request:
branches: [main, develop]
paths:
- 'src/components/**'
- 'src/stories/**'
- '.storybook/**'
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build Storybook
run: yarn build-storybook
- name: Run Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
buildScriptName: 'build-storybook'
onlyChanged: true
exitOnceUploaded: true
- name: Run accessibility tests
run: yarn test-storybook --coverage
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
test-results/
coverage/
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
if (fs.existsSync('chromatic-diagnostics.json')) {
const diagnostics = JSON.parse(fs.readFileSync('chromatic-diagnostics.json'));
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## ビジュアルテスト結果\n\n✅ Chromatic build: ${diagnostics.buildUrl}`
});
}
品質メトリクスの測定
typescript// scripts/design-quality-metrics.ts
interface QualityMetric {
component: string;
pixelAccuracy: number; // ピクセル精度 (%)
colorAccuracy: number; // カラー精度 (%)
fontAccuracy: number; // フォント精度 (%)
overallScore: number; // 総合スコア
lastUpdated: string;
}
class DesignQualityAnalyzer {
private metrics: QualityMetric[] = [];
async measureQuality(
componentName: string
): Promise<QualityMetric> {
try {
// 実際の測定ロジック
const pixelDiff = await this.measurePixelDifference(
componentName
);
const colorDiff = await this.measureColorDifference(
componentName
);
const fontDiff = await this.measureFontDifference(
componentName
);
const pixelAccuracy = Math.max(0, 100 - pixelDiff);
const colorAccuracy = Math.max(0, 100 - colorDiff);
const fontAccuracy = Math.max(0, 100 - fontDiff);
const overallScore =
(pixelAccuracy + colorAccuracy + fontAccuracy) / 3;
const metric: QualityMetric = {
component: componentName,
pixelAccuracy: Math.round(pixelAccuracy * 10) / 10,
colorAccuracy: Math.round(colorAccuracy * 10) / 10,
fontAccuracy: Math.round(fontAccuracy * 10) / 10,
overallScore: Math.round(overallScore * 10) / 10,
lastUpdated: new Date().toISOString(),
};
this.metrics.push(metric);
return metric;
} catch (error) {
console.error(
`Error measuring quality for ${componentName}:`,
error
);
throw error;
}
}
private async measurePixelDifference(
componentName: string
): Promise<number> {
// Puppeteer を使ったスクリーンショット比較
// Figma API からデザイン画像を取得
// 画像比較ライブラリで差分計算
return Math.random() * 5; // 仮実装
}
private async measureColorDifference(
componentName: string
): Promise<number> {
// CSS 計算値とFigmaデザインの色を比較
// Delta E 色差計算
return Math.random() * 3; // 仮実装
}
private async measureFontDifference(
componentName: string
): Promise<number> {
// フォントサイズ、行間、文字詰めの差異測定
return Math.random() * 2; // 仮実装
}
generateReport(): string {
const averageScore =
this.metrics.reduce(
(sum, metric) => sum + metric.overallScore,
0
) / this.metrics.length;
let report = `# デザイン品質レポート\n\n`;
report += `## 全体サマリー\n`;
report += `- 総合スコア: ${averageScore.toFixed(
1
)}点\n`;
report += `- 測定コンポーネント数: ${this.metrics.length}個\n\n`;
report += `## コンポーネント別詳細\n\n`;
report += `| コンポーネント | ピクセル精度 | カラー精度 | フォント精度 | 総合スコア |\n`;
report += `|---------------|-------------|-----------|-------------|----------|\n`;
this.metrics.forEach((metric) => {
report += `| ${metric.component} | ${metric.pixelAccuracy}% | ${metric.colorAccuracy}% | ${metric.fontAccuracy}% | ${metric.overallScore}点 |\n`;
});
return report;
}
}
// 使用例
const analyzer = new DesignQualityAnalyzer();
async function runQualityCheck() {
const components = ['Button', 'Input', 'Card', 'Modal'];
for (const component of components) {
await analyzer.measureQuality(component);
}
const report = analyzer.generateReport();
console.log(report);
// Slack やメールでの通知
await sendQualityReport(report);
}
async function sendQualityReport(report: string) {
// Slack Webhook での通知実装
try {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: '🎨 デザイン品質レポートが更新されました',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: report,
},
},
],
}),
});
} catch (error) {
console.error('Failed to send quality report:', error);
}
}
レビュープロセスの自動化
typescript// scripts/automated-review.ts
interface ReviewCheckItem {
name: string;
check: () => Promise<boolean>;
severity: 'error' | 'warning' | 'info';
message: string;
}
class AutomatedReviewSystem {
private checks: ReviewCheckItem[] = [
{
name: 'color-contrast',
check: async () => this.checkColorContrast(),
severity: 'error',
message:
'カラーコントラストが WCAG 基準を満たしていません',
},
{
name: 'font-size-consistency',
check: async () => this.checkFontSizeConsistency(),
severity: 'warning',
message:
'フォントサイズがデザインシステムと一致していません',
},
{
name: 'spacing-consistency',
check: async () => this.checkSpacingConsistency(),
severity: 'warning',
message:
'スペーシングがデザイントークンと一致していません',
},
{
name: 'component-api-consistency',
check: async () =>
this.checkComponentAPIConsistency(),
severity: 'info',
message:
'コンポーネント API がガイドラインと異なります',
},
];
async runAllChecks(): Promise<{
passed: ReviewCheckItem[];
failed: ReviewCheckItem[];
}> {
const results = await Promise.all(
this.checks.map(async (check) => ({
check,
passed: await check.check(),
}))
);
return {
passed: results
.filter((r) => r.passed)
.map((r) => r.check),
failed: results
.filter((r) => !r.passed)
.map((r) => r.check),
};
}
private async checkColorContrast(): Promise<boolean> {
// 実装:アクセシビリティチェック
return true; // 仮実装
}
private async checkFontSizeConsistency(): Promise<boolean> {
// 実装:フォントサイズの一貫性チェック
return true; // 仮実装
}
private async checkSpacingConsistency(): Promise<boolean> {
// 実装:スペーシングの一貫性チェック
return true; // 仮実装
}
private async checkComponentAPIConsistency(): Promise<boolean> {
// 実装:コンポーネント API の一貫性チェック
return true; // 仮実装
}
}
まとめ
Figma デザインの Web 実装における正確な再現は、技術的な理解と細やかな調整の積み重ねによって実現されます。本記事で紹介した 8 つのテクニックを活用することで、デザイナーの意図を忠実に Web 上で表現できるようになります。
特に重要なのは以下の点です:
技術的なポイント
- ピクセルパーフェクトな実装 - わずかなずれも許さない精密さが求められます
- レスポンシブ対応 - 全てのデバイスでの一貫した表示を実現する必要があります
- パフォーマンス - 美しさと速度の両立が重要です
- 保守性 - 長期的に維持しやすいコード設計を心がけましょう
プロセス面での改善
- 自動化の活用 - CI/CD パイプラインでの品質チェック自動化
- 継続的な改善 - メトリクス測定による品質向上
- チーム連携 - デザイナーとエンジニアの効果的な協働
- 標準化 - チーム内でのガイドライン策定と共有
成功への鍵
最も大切なのは、技術的な完璧さを追求するだけでなく、ユーザーにとって本当に価値のある体験を提供することです。Figma デザインの再現は手段であり、目的はユーザーの課題解決にあることを忘れてはいけません。
Storybook を活用した検証環境の構築により、実装の品質を継続的に向上させることができます。また、自動化されたテストとレビュープロセスにより、チーム全体でのデザイン品質を担保できるでしょう。
これらのテクニックを実践することで、ユーザーにとって美しく使いやすい Web アプリケーションの開発が実現できるはずです。継続的な改善と学習を通じて、より高品質なプロダクトを作り上げていきましょう。