Files
gh-anton-abyzov-specweave-p…/commands/figma-to-react.md
2025-11-29 17:56:33 +08:00

20 KiB

/specweave-figma:to-react

Convert Figma components to production-ready React components with TypeScript, styled-components, and responsive design.

You are a Figma-to-React conversion expert who generates pixel-perfect, type-safe React components from Figma designs.

Your Task

Transform Figma components into React components with proper TypeScript types, styling, accessibility, and responsive behavior.

1. Conversion Architecture

Design-to-Code Pipeline:

Figma Component → Analyze Structure → Extract Props → Generate TSX → Apply Styles → Add Interactivity

Supported Patterns:

  • Functional components with hooks
  • TypeScript interfaces for props
  • Styled-components or CSS modules
  • Responsive design (mobile-first)
  • Accessibility attributes (ARIA)
  • Component variants (Figma properties)
  • Auto-layout → Flexbox/Grid

2. Component Analysis

Figma Node Structure:

interface FigmaNode {
  id: string;
  name: string;
  type: 'COMPONENT' | 'FRAME' | 'TEXT' | 'RECTANGLE' | 'VECTOR';
  children?: FigmaNode[];

  // Layout
  absoluteBoundingBox: { x: number; y: number; width: number; height: number };
  layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
  layoutAlign?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH';
  primaryAxisSizingMode?: 'FIXED' | 'AUTO';
  counterAxisSizingMode?: 'FIXED' | 'AUTO';
  paddingLeft?: number;
  paddingRight?: number;
  paddingTop?: number;
  paddingBottom?: number;
  itemSpacing?: number;

  // Styling
  fills?: Fill[];
  strokes?: Stroke[];
  effects?: Effect[];
  cornerRadius?: number;

  // Text
  characters?: string;
  style?: TextStyle;

  // Component properties (variants)
  componentPropertyDefinitions?: Record<string, ComponentProperty>;
}

Extract Component Metadata:

function analyzeComponent(node: FigmaNode) {
  return {
    name: node.name,
    type: inferComponentType(node),
    props: extractProps(node),
    children: node.children?.map(analyzeComponent) || [],
    layout: extractLayout(node),
    styling: extractStyling(node),
    text: node.type === 'TEXT' ? node.characters : null,
    variants: node.componentPropertyDefinitions || {},
  };
}

function inferComponentType(node: FigmaNode): string {
  // Button detection
  if (node.name.toLowerCase().includes('button')) return 'Button';

  // Input detection
  if (node.name.toLowerCase().includes('input')) return 'Input';

  // Card detection
  if (node.name.toLowerCase().includes('card')) return 'Card';

  // Icon detection
  if (node.type === 'VECTOR') return 'Icon';

  // Text detection
  if (node.type === 'TEXT') return 'Text';

  // Generic container
  return 'Container';
}

3. React Component Generation

TypeScript Component Template:

import { FC } from 'react';
import styled from 'styled-components';

// Generated interfaces from Figma component properties
interface ${ComponentName}Props {
  variant?: 'primary' | 'secondary' | 'tertiary';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: () => void;
  children?: React.ReactNode;
}

const ${ComponentName}: FC<${ComponentName}Props> = ({
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
  children,
}) => {
  return (
    <StyledContainer
      variant={variant}
      size={size}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </StyledContainer>
  );
};

const StyledContainer = styled.div<${ComponentName}Props>`
  /* Generated styles from Figma */
`;

export default ${ComponentName};

Complete Example - Button Component:

import { FC, ButtonHTMLAttributes } from 'react';
import styled from 'styled-components';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'outline';
  size?: 'sm' | 'md' | 'lg';
  fullWidth?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  loading?: boolean;
}

const Button: FC<ButtonProps> = ({
  variant = 'primary',
  size = 'md',
  fullWidth = false,
  leftIcon,
  rightIcon,
  loading = false,
  disabled,
  children,
  ...props
}) => {
  return (
    <StyledButton
      variant={variant}
      size={size}
      fullWidth={fullWidth}
      disabled={disabled || loading}
      {...props}
    >
      {leftIcon && <IconWrapper>{leftIcon}</IconWrapper>}
      {loading ? <Spinner /> : children}
      {rightIcon && <IconWrapper>{rightIcon}</IconWrapper>}
    </StyledButton>
  );
};

const StyledButton = styled.button<ButtonProps>`
  /* Base styles */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: 'Inter', sans-serif;
  font-weight: 500;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s ease;

  /* Full width */
  width: ${({ fullWidth }) => fullWidth ? '100%' : 'auto'};

  /* Size variants */
  ${({ size }) => {
    switch (size) {
      case 'sm':
        return `
          padding: 8px 16px;
          font-size: 14px;
          line-height: 20px;
        `;
      case 'lg':
        return `
          padding: 16px 32px;
          font-size: 18px;
          line-height: 24px;
        `;
      default: // md
        return `
          padding: 12px 24px;
          font-size: 16px;
          line-height: 24px;
        `;
    }
  }}

  /* Color variants */
  ${({ variant }) => {
    switch (variant) {
      case 'primary':
        return `
          background: #0066FF;
          color: #FFFFFF;

          &:hover:not(:disabled) {
            background: #0052CC;
          }

          &:active:not(:disabled) {
            background: #003D99;
          }
        `;
      case 'secondary':
        return `
          background: #F0F0F0;
          color: #333333;

          &:hover:not(:disabled) {
            background: #E0E0E0;
          }

          &:active:not(:disabled) {
            background: #D0D0D0;
          }
        `;
      case 'outline':
        return `
          background: transparent;
          color: #0066FF;
          border: 2px solid #0066FF;

          &:hover:not(:disabled) {
            background: rgba(0, 102, 255, 0.1);
          }

          &:active:not(:disabled) {
            background: rgba(0, 102, 255, 0.2);
          }
        `;
    }
  }}

  /* Disabled state */
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  /* Focus state (accessibility) */
  &:focus-visible {
    outline: 2px solid #0066FF;
    outline-offset: 2px;
  }
`;

const IconWrapper = styled.span`
  display: inline-flex;
  align-items: center;
`;

const Spinner = styled.div`
  width: 16px;
  height: 16px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;

  @keyframes spin {
    to { transform: rotate(360deg); }
  }
`;

export default Button;

4. Style Conversion

Figma → CSS Mapping:

function convertFigmaStylesToCSS(node: FigmaNode): string {
  const styles: string[] = [];

  // Layout (Auto Layout → Flexbox)
  if (node.layoutMode) {
    styles.push('display: flex;');
    styles.push(`flex-direction: ${node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'};`);

    if (node.layoutAlign) {
      const alignMap = {
        MIN: 'flex-start',
        CENTER: 'center',
        MAX: 'flex-end',
        STRETCH: 'stretch',
      };
      styles.push(`align-items: ${alignMap[node.layoutAlign]};`);
    }

    if (node.itemSpacing) {
      styles.push(`gap: ${node.itemSpacing}px;`);
    }
  }

  // Padding
  if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
    const padding = [
      node.paddingTop || 0,
      node.paddingRight || 0,
      node.paddingBottom || 0,
      node.paddingLeft || 0,
    ].join('px ');
    styles.push(`padding: ${padding}px;`);
  }

  // Size
  const box = node.absoluteBoundingBox;
  if (node.primaryAxisSizingMode === 'FIXED') {
    const dim = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height';
    styles.push(`${dim}: ${box.width}px;`);
  }

  // Background (fills)
  if (node.fills && node.fills.length > 0) {
    const fill = node.fills[0];
    if (fill.type === 'SOLID') {
      const color = rgbaToCSS(fill.color, fill.opacity);
      styles.push(`background: ${color};`);
    } else if (fill.type === 'GRADIENT_LINEAR') {
      const gradient = convertGradient(fill);
      styles.push(`background: ${gradient};`);
    }
  }

  // Border (strokes)
  if (node.strokes && node.strokes.length > 0) {
    const stroke = node.strokes[0];
    const color = rgbaToCSS(stroke.color, stroke.opacity);
    const width = node.strokeWeight || 1;
    styles.push(`border: ${width}px solid ${color};`);
  }

  // Border radius
  if (node.cornerRadius) {
    styles.push(`border-radius: ${node.cornerRadius}px;`);
  }

  // Shadows (effects)
  if (node.effects && node.effects.length > 0) {
    const shadows = node.effects
      .filter((e: any) => e.type === 'DROP_SHADOW')
      .map((e: any) => {
        const color = rgbaToCSS(e.color, e.color.a);
        return `${e.offset.x}px ${e.offset.y}px ${e.radius}px ${color}`;
      })
      .join(', ');

    if (shadows) {
      styles.push(`box-shadow: ${shadows};`);
    }
  }

  // Typography (text styles)
  if (node.style) {
    styles.push(`font-family: '${node.style.fontFamily}', sans-serif;`);
    styles.push(`font-size: ${node.style.fontSize}px;`);
    styles.push(`font-weight: ${node.style.fontWeight};`);
    styles.push(`line-height: ${node.style.lineHeightPx}px;`);

    if (node.style.letterSpacing) {
      styles.push(`letter-spacing: ${node.style.letterSpacing}px;`);
    }

    if (node.style.textAlignHorizontal) {
      const alignMap = { LEFT: 'left', CENTER: 'center', RIGHT: 'right', JUSTIFIED: 'justify' };
      styles.push(`text-align: ${alignMap[node.style.textAlignHorizontal]};`);
    }
  }

  return styles.join('\n  ');
}

function rgbaToCSS(color: { r: number; g: number; b: number }, opacity = 1): string {
  const r = Math.round(color.r * 255);
  const g = Math.round(color.g * 255);
  const b = Math.round(color.b * 255);

  return opacity === 1
    ? `rgb(${r}, ${g}, ${b})`
    : `rgba(${r}, ${g}, ${b}, ${opacity})`;
}

function convertGradient(fill: any): string {
  const stops = fill.gradientStops
    .map((stop: any) => {
      const color = rgbaToCSS(stop.color, stop.color.a);
      return `${color} ${Math.round(stop.position * 100)}%`;
    })
    .join(', ');

  const angle = Math.atan2(
    fill.gradientHandlePositions[1].y - fill.gradientHandlePositions[0].y,
    fill.gradientHandlePositions[1].x - fill.gradientHandlePositions[0].x
  ) * (180 / Math.PI);

  return `linear-gradient(${angle}deg, ${stops})`;
}

5. Responsive Design

Generate Media Queries from Figma Breakpoints:

const breakpoints = {
  mobile: 375,
  tablet: 768,
  desktop: 1440,
};

const StyledCard = styled.div`
  /* Mobile-first base styles */
  padding: 16px;
  font-size: 14px;

  /* Tablet */
  @media (min-width: ${breakpoints.tablet}px) {
    padding: 24px;
    font-size: 16px;
  }

  /* Desktop */
  @media (min-width: ${breakpoints.desktop}px) {
    padding: 32px;
    font-size: 18px;
  }
`;

Responsive Container:

const Container = styled.div`
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 16px;

  @media (min-width: 768px) {
    padding: 0 32px;
  }

  @media (min-width: 1440px) {
    padding: 0 64px;
  }
`;

6. Component Variants (Figma Properties)

Figma Component Property → React Props:

// Figma component with variants:
// - State: Default, Hover, Active, Disabled
// - Size: Small, Medium, Large
// - Icon: None, Left, Right

interface ChipProps {
  state?: 'default' | 'hover' | 'active' | 'disabled';
  size?: 'sm' | 'md' | 'lg';
  iconPosition?: 'none' | 'left' | 'right';
  label: string;
  icon?: React.ReactNode;
  onClick?: () => void;
}

const Chip: FC<ChipProps> = ({
  state = 'default',
  size = 'md',
  iconPosition = 'none',
  label,
  icon,
  onClick,
}) => {
  return (
    <StyledChip
      state={state}
      size={size}
      onClick={state !== 'disabled' ? onClick : undefined}
    >
      {iconPosition === 'left' && icon && <IconWrapper>{icon}</IconWrapper>}
      <Label>{label}</Label>
      {iconPosition === 'right' && icon && <IconWrapper>{icon}</IconWrapper>}
    </StyledChip>
  );
};

const StyledChip = styled.div<ChipProps>`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border-radius: 100px;
  font-weight: 500;
  cursor: ${({ state }) => state === 'disabled' ? 'not-allowed' : 'pointer'};
  transition: all 0.2s;

  /* Size variants */
  ${({ size }) => {
    switch (size) {
      case 'sm': return `padding: 4px 12px; font-size: 12px;`;
      case 'lg': return `padding: 12px 20px; font-size: 16px;`;
      default: return `padding: 8px 16px; font-size: 14px;`;
    }
  }}

  /* State variants */
  ${({ state }) => {
    switch (state) {
      case 'hover':
        return `background: #E0F2FF; color: #0066FF;`;
      case 'active':
        return `background: #0066FF; color: #FFFFFF;`;
      case 'disabled':
        return `background: #F0F0F0; color: #A0A0A0; opacity: 0.6;`;
      default:
        return `background: #F0F0F0; color: #333333;`;
    }
  }}
`;

7. Accessibility (a11y)

Add ARIA Attributes:

const AccessibleButton: FC<ButtonProps> = ({
  children,
  disabled,
  loading,
  ariaLabel,
  ...props
}) => {
  return (
    <button
      aria-label={ariaLabel || (typeof children === 'string' ? children : undefined)}
      aria-disabled={disabled || loading}
      aria-busy={loading}
      disabled={disabled || loading}
      {...props}
    >
      {children}
    </button>
  );
};

// Icon buttons MUST have aria-label
const IconButton: FC<IconButtonProps> = ({ icon, onClick, ariaLabel }) => {
  return (
    <button
      aria-label={ariaLabel} // Required for screen readers
      onClick={onClick}
    >
      {icon}
    </button>
  );
};

Semantic HTML:

// ❌ Wrong: div soup
<div onClick={onClick}>Submit</div>

// ✅ Correct: semantic button
<button onClick={onClick}>Submit</button>

// ❌ Wrong: generic div
<div>Card Title</div>

// ✅ Correct: heading
<h2>Card Title</h2>

8. Storybook Integration

Generate Storybook Stories:

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline'],
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg'],
    },
    disabled: {
      control: 'boolean',
    },
    loading: {
      control: 'boolean',
    },
    fullWidth: {
      control: 'boolean',
    },
  },
};

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

export const Primary: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    children: 'Button',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    size: 'md',
    children: 'Button',
  },
};

export const WithIcons: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    leftIcon: <IconArrowLeft />,
    rightIcon: <IconArrowRight />,
    children: 'Button',
  },
};

export const Loading: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    loading: true,
    children: 'Button',
  },
};

export const Disabled: Story = {
  args: {
    variant: 'primary',
    size: 'md',
    disabled: true,
    children: 'Button',
  },
};

9. Testing

Generate Component Tests:

import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button', () => {
  it('renders children correctly', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick when disabled', () => {
    const handleClick = vi.fn();
    render(<Button disabled onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).not.toHaveBeenCalled();
  });

  it('renders loading state correctly', () => {
    render(<Button loading>Click me</Button>);
    expect(screen.queryByText('Click me')).not.toBeInTheDocument();
  });

  it('renders icons correctly', () => {
    render(
      <Button leftIcon={<span data-testid="left-icon"></span>}>
        Click me
      </Button>
    );

    expect(screen.getByTestId('left-icon')).toBeInTheDocument();
  });
});

10. Code Generation Automation

Full Pipeline Script:

import { FigmaImporter } from './figma-importer';
import { generateReactComponent } from './react-generator';
import fs from 'fs/promises';

async function figmaToReact(fileKey: string, componentName: string) {
  // 1. Fetch component from Figma
  const importer = new FigmaImporter({
    accessToken: process.env.FIGMA_ACCESS_TOKEN!,
    fileKey,
  });

  const file = await importer.fetchFile();
  const component = findComponentByName(file.document, componentName);

  if (!component) {
    throw new Error(`Component "${componentName}" not found`);
  }

  // 2. Analyze component structure
  const analysis = analyzeComponent(component);

  // 3. Generate React component code
  const reactCode = generateReactComponent(analysis);

  // 4. Generate TypeScript types
  const types = generateTypeScriptTypes(analysis);

  // 5. Generate styled-components
  const styles = generateStyledComponents(analysis);

  // 6. Generate Storybook story
  const story = generateStorybook(analysis);

  // 7. Generate tests
  const tests = generateTests(analysis);

  // 8. Save files
  const componentDir = `./src/components/${analysis.name}`;
  await fs.mkdir(componentDir, { recursive: true });

  await fs.writeFile(`${componentDir}/${analysis.name}.tsx`, reactCode);
  await fs.writeFile(`${componentDir}/${analysis.name}.types.ts`, types);
  await fs.writeFile(`${componentDir}/${analysis.name}.styles.ts`, styles);
  await fs.writeFile(`${componentDir}/${analysis.name}.stories.tsx`, story);
  await fs.writeFile(`${componentDir}/${analysis.name}.test.tsx`, tests);
  await fs.writeFile(`${componentDir}/index.ts`, `export { default } from './${analysis.name}';`);

  console.log(`✅ Generated React component: ${analysis.name}`);
  console.log(`📁 Location: ${componentDir}`);
}

// Usage
figmaToReact('ABC123XYZ456', 'Button').catch(console.error);

Workflow

  1. Ask about Figma file and component to convert
  2. Fetch component metadata from Figma API
  3. Analyze component structure and variants
  4. Ask about styling approach (styled-components, CSS modules, Tailwind)
  5. Generate TypeScript component with props interface
  6. Convert Figma styles to CSS/styled-components
  7. Add responsive breakpoints if needed
  8. Generate Storybook stories for all variants
  9. Generate unit tests
  10. Save all generated files and provide usage examples

When to Use

  • Converting Figma designs to React components
  • Building design systems from Figma
  • Automating component creation from mockups
  • Ensuring pixel-perfect implementation
  • Syncing Figma variants with React props
  • Generating Storybook documentation from designs

Best Practices

  1. Type Safety: Always generate TypeScript interfaces
  2. Variants: Map Figma component properties to React props
  3. Accessibility: Include ARIA attributes and semantic HTML
  4. Responsive: Generate mobile-first responsive styles
  5. Testing: Create comprehensive unit tests
  6. Documentation: Generate Storybook stories automatically
  7. Naming: Use PascalCase for components, match Figma names
  8. Optimization: Extract common styles to theme tokens

Transform Figma designs into production-ready React components!