T-CREATOR

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

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

デザイナーが丹精込めて作成した Figma デザインを、Web 上で完璧に再現したいと思ったことはありませんか?しかし実際には、デザインツールと Web 実装の間には大きなギャップが存在し、多くの開発者がその壁に直面しています。

「デザイン通りに作ったつもりなのに、なぜか違和感がある」「微妙なずれが気になるけれど、どう修正すればいいかわからない」そんな悩みを抱えている方も多いのではないでしょうか。

本記事では、Figma デザインを Web 上で正確に再現するための具体的なテクニックを、実装レベルで詳しく解説いたします。CSS の細かな調整方法から Storybook での検証環境構築まで、現場で即座に活用できる実践的な手法をお伝えします。

Figma と Web 実装のギャップ分析

デザインツールと Web の違い

Figma と Web ブラウザでは、レンダリングエンジンや表示の仕組みが根本的に異なります。この違いを理解することが、正確な再現への第一歩となります。

要素FigmaWeb ブラウザ主な課題
座標システム絶対座標ベースボックスモデルベースレイアウト崩れが発生しやすい
フォントレンダリング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 上で表現できるようになります。

特に重要なのは以下の点です:

技術的なポイント

  1. ピクセルパーフェクトな実装 - わずかなずれも許さない精密さが求められます
  2. レスポンシブ対応 - 全てのデバイスでの一貫した表示を実現する必要があります
  3. パフォーマンス - 美しさと速度の両立が重要です
  4. 保守性 - 長期的に維持しやすいコード設計を心がけましょう

プロセス面での改善

  1. 自動化の活用 - CI/CD パイプラインでの品質チェック自動化
  2. 継続的な改善 - メトリクス測定による品質向上
  3. チーム連携 - デザイナーとエンジニアの効果的な協働
  4. 標準化 - チーム内でのガイドライン策定と共有

成功への鍵

最も大切なのは、技術的な完璧さを追求するだけでなく、ユーザーにとって本当に価値のある体験を提供することです。Figma デザインの再現は手段であり、目的はユーザーの課題解決にあることを忘れてはいけません。

Storybook を活用した検証環境の構築により、実装の品質を継続的に向上させることができます。また、自動化されたテストとレビュープロセスにより、チーム全体でのデザイン品質を担保できるでしょう。

これらのテクニックを実践することで、ユーザーにとって美しく使いやすい Web アプリケーションの開発が実現できるはずです。継続的な改善と学習を通じて、より高品質なプロダクトを作り上げていきましょう。

関連リンク