UTA DevHub

Custom Icons Guide

Complete workflow for implementing custom designer SVGs with flat folder structure, SVGR automation, and icon font creation

Custom Icons Implementation Guide

Overview

Custom icons enable perfect brand alignment while maintaining systematic UI iconography. This guide covers the complete workflow from designer export to React Native implementation using a flat folder structure with SVGR automation.

Scope: This covers UI icons only. For brand assets (logos, complex illustrations), see the Brand Assets Overview.

When to Use Custom Icons

Use Custom Icons For:

  • Brand-specific UI patterns - Navigation styles unique to your brand
  • Custom action icons - Actions not available in standard libraries
  • Consistent design language - Icons that match your exact design system
  • Specialized functionality - Domain-specific actions or states

🤔 Consider Vector Icons Instead:

  • Standard UI patterns - Home, search, menu, settings
  • MVP development - Speed over perfect brand alignment
  • Small teams - Limited design resources

Flat Folder Structure Architecture

Core Principle: Simplicity

ui/foundation/icons/custom/     # Flat structure - no subfolders
├── ArrowLeftIcon.tsx          # Navigation icons
├── ArrowRightIcon.tsx
├── MenuIcon.tsx
├── CloseIcon.tsx
├── SearchIcon.tsx             # Action icons  
├── EditIcon.tsx
├── DeleteIcon.tsx
├── SaveIcon.tsx
├── HeartIcon.tsx              # Status/social icons
├── StarIcon.tsx
├── NotificationIcon.tsx
├── types.ts                   # TypeScript definitions
└── index.ts                   # Barrel exports

Why Flat Structure?

  • (Do ✅) Easy discovery and navigation
  • (Do ✅) Simplified imports and maintenance
  • (Do ✅) No deep nesting complexity
  • (Do ✅) Consistent with foundation component patterns

Designer-Developer Workflow

Phase 1: Designer Guidelines

Designer Handoff Standards

To ensure smooth implementation, establish these guidelines with your design team:

Export Requirements

RequirementSpecificationWhy Important
Artboard Size24x24px (consistent viewBox)Scalable baseline for all screen densities
Stroke Width2px (consistent across all icons)Visual consistency and clarity
ColorsSingle color or currentColorEnables theme-based color changes
FormatSVG (optimized export)Vector format for perfect scaling
Namingkebab-case (arrow-left, user-profile)Consistent with component naming

Design Checklist

  • (Do ✅) Use consistent 24x24px artboard for all icons
  • (Do ✅) Apply 2px stroke width throughout
  • (Do ✅) Remove background fills and unnecessary elements
  • (Do ✅) Use single color (#000000 or currentColor)
  • (Do ✅) Optimize paths and reduce complexity
  • (Don't ❌) Include multiple colors in UI icons
  • (Don't ❌) Use different stroke widths within icon set
  • (Don't ❌) Export with embedded text or complex effects

Phase 2: Export Process

Designer Export Setup

From Figma:

# Export settings
Format: SVG
Export options: 
  - Remove "Include id attribute" 
  - Enable "Optimize SVG"
  - Use outline stroke

Naming Convention:

arrow-left.svg        # ✅ kebab-case
user-profile.svg      # ✅ descriptive
shopping-cart.svg     # ✅ clear purpose

# Avoid:
ArrowLeft.svg         # ❌ PascalCase  
arrow_left.svg        # ❌ snake_case
icon1.svg             # ❌ non-descriptive

Export Location

# Designer places exports here
mkdir -p assets/icons/custom-exports/
 
# Directory structure
assets/icons/
├── custom-exports/           # Designer SVG files
   ├── arrow-left.svg
   ├── menu.svg
   ├── search.svg
   └── heart.svg
└── optimized/               # Processed SVGs
    ├── arrow-left.svg
    ├── menu.svg  
    ├── search.svg
    └── heart.svg

Technical Implementation

Dependencies Setup

# Core dependencies
npm install react-native-svg
 
# Development dependencies  
npm install --save-dev @svgr/cli svgo
 
# Optional: For icon fonts
npm install --save-dev fanticon

Configuration Files

SVGO Configuration

// svgo.config.js
module.exports = {
  plugins: [
    {
      name: 'preset-default',
      params: {
        overrides: {
          removeViewBox: false, // Keep viewBox for proper scaling
          removeTitle: false,   // Keep title for accessibility
        },
      },
    },
    {
      name: 'addAttributesToSVGElement',
      params: {
        attributes: [
          { xmlns: 'http://www.w3.org/2000/svg' }
        ],
      },
    },
    {
      name: 'removeDimensions', // Remove width/height, keep viewBox
    },
  ],
};

SVGR Configuration

// svgr.config.js
module.exports = {
  native: true,
  typescript: true,
  icon: true,
  replaceAttrValues: {
    '#000': 'currentColor',
    '#000000': 'currentColor',
    'black': 'currentColor',
  },
  svgProps: {
    width: '{size}',
    height: '{size}',
    fill: '{color}',
  },
  template: (variables, { tpl }) => {
    return tpl`
${variables.imports};
 
interface ${variables.componentName}Props {
  size?: number;
  color?: string;
}
 
const ${variables.componentName}: React.FC<${variables.componentName}Props> = ({ 
  size = 24, 
  color = 'currentColor',
  ...props 
}) => (
  ${variables.jsx}
);
 
${variables.exports};
`;
  },
};

Automation Scripts

Processing Pipeline

#!/bin/bash
# scripts/process-custom-icons.sh
 
set -e
 
echo "🎨 Processing custom icons..."
 
# Step 1: Optimize SVGs
echo "📦 Optimizing SVGs with SVGO..."
npx svgo \
  --config svgo.config.js \
  --input assets/icons/custom-exports \
  --output assets/icons/optimized \
  --recursive
 
# Step 2: Generate React Native components
echo "⚛️ Generating React Native components..."
npx @svgr/cli \
  --config-file svgr.config.js \
  --out-dir ui/foundation/icons/custom \
  --typescript \
  assets/icons/optimized
 
# Step 3: Generate barrel exports
echo "📋 Generating barrel exports..."
node scripts/generate-icon-exports.js
 
echo "✅ Custom icons processed successfully!"
echo "📁 Components available in: ui/foundation/icons/custom/"

Export Generation

// scripts/generate-icon-exports.js
const fs = require('fs');
const path = require('path');
 
const iconsDir = path.join(__dirname, '../ui/foundation/icons/custom');
const outputFile = path.join(iconsDir, 'index.ts');
 
// Read all .tsx files
const iconFiles = fs.readdirSync(iconsDir)
  .filter(file => file.endsWith('.tsx') && file !== 'index.tsx')
  .map(file => file.replace('.tsx', ''));
 
// Generate exports
const exports = iconFiles
  .map(name => `export { default as ${name} } from './${name}';`)
  .join('\n');
 
// Generate type union
const iconNames = iconFiles
  .map(name => name.replace('Icon', '').toLowerCase())
  .map(name => `'${name}'`)
  .join(' | ');
 
const content = `// Auto-generated file - do not edit manually
${exports}
 
// Type definition for custom icon names
export type CustomIconName = ${iconNames};
 
// Icon component mapping
export const customIcons = {
${iconFiles.map(name => {
  const iconName = name.replace('Icon', '').toLowerCase();
  return `  '${iconName}': ${name},`;
}).join('\n')}
} as const;
`;
 
fs.writeFileSync(outputFile, content);
console.log(`✅ Generated exports for ${iconFiles.length} icons`);

Package.json Scripts

{
  "scripts": {
    "icons:process": "bash scripts/process-custom-icons.sh",
    "icons:optimize": "npx svgo --config svgo.config.js --input assets/icons/custom-exports --output assets/icons/optimized --recursive",
    "icons:generate": "npx @svgr/cli --config-file svgr.config.js --out-dir ui/foundation/icons/custom --typescript assets/icons/optimized",
    "icons:exports": "node scripts/generate-icon-exports.js"
  }
}

Registry Integration

Custom Icon Registry

// ui/foundation/Icon/registry.ts
import { customIcons, CustomIconName } from '../icons/custom';
 
export const IconRegistry = {
  // Vector icons (from previous guide)
  vector: {
    ionicons: { /* ... */ },
    material: { /* ... */ },
    fontAwesome: { /* ... */ },
  },
  
  // Custom icons (flat structure)
  custom: customIcons,
  
  // Icon fonts (if needed)
  fonts: {
    // Custom icon font mappings
  },
} as const;
 
// Type definitions
export type VectorIconName = keyof typeof IconRegistry.vector.ionicons;
export type { CustomIconName };
export type IconName = VectorIconName | CustomIconName;

Unified Icon Component Update

// ui/foundation/Icon/Icon.tsx
import React from 'react';
import Ionicons from '@expo/vector-icons/Ionicons';
import { IconRegistry, VectorIconName, CustomIconName } from './registry';
 
interface IconProps {
  name: VectorIconName | CustomIconName;
  size?: number;
  color?: string;
  library?: 'vector' | 'custom';
}
 
export const Icon: React.FC<IconProps> = ({ 
  name, 
  size = 24, 
  color = 'currentColor',
  library = 'vector'
}) => {
  // Auto-detect library if not specified
  const detectedLibrary = library === 'vector' 
    ? (name in IconRegistry.vector.ionicons ? 'vector' : 'custom')
    : library;
 
  switch (detectedLibrary) {
    case 'vector':
      return (
        <Ionicons 
          name={IconRegistry.vector.ionicons[name as VectorIconName]} 
          size={size} 
          color={color} 
        />
      );
    
    case 'custom':
      const CustomIconComponent = IconRegistry.custom[name as CustomIconName];
      if (!CustomIconComponent) {
        console.warn(`Custom icon "${name}" not found`);
        return null;
      }
      return <CustomIconComponent size={size} color={color} />;
    
    default:
      console.warn(`Unknown icon library: ${detectedLibrary}`);
      return null;
  }
};

Usage Examples

Basic Implementation

// Basic custom icon usage
<Icon name="arrow-left" library="custom" size={24} color="primary" />
<Icon name="menu" library="custom" size={32} />
<Icon name="heart" library="custom" size={20} color="red" />
 
// Auto-detection (falls back to custom if not found in vector)
<Icon name="brand-specific-action" size={24} />

In Navigation Components

// Custom navigation with brand-specific icons
export const CustomHeader: React.FC = () => {
  return (
    <View style={styles.header}>
      <TouchableOpacity onPress={handleBack}>
        <Icon name="arrow-left" library="custom" size={24} color="white" />
      </TouchableOpacity>
      
      <Text style={styles.title}>Settings</Text>
      
      <TouchableOpacity onPress={handleMenu}>
        <Icon name="menu" library="custom" size={24} color="white" />
      </TouchableOpacity>
    </View>
  );
};

Theme Integration

// Theme-aware custom icons
export const ThemedIcon: React.FC<ThemedIconProps> = ({ 
  name, 
  size = 'md',
  variant = 'primary' 
}) => {
  const theme = useTheme();
  
  const sizeMap = {
    sm: 16,
    md: 24, 
    lg: 32,
    xl: 48,
  };
  
  const colorMap = {
    primary: theme.colors.primary,
    secondary: theme.colors.secondary,
    accent: theme.colors.accent,
    muted: theme.colors.textMuted,
  };
  
  return (
    <Icon 
      name={name}
      library="custom"
      size={typeof size === 'number' ? size : sizeMap[size]}
      color={colorMap[variant]}
    />
  );
};

Icon Font Generation (Advanced)

When to Consider Icon Fonts

  • Large custom icon sets (50+ icons)
  • Performance optimization needed
  • Legacy platform support required
  • Consistent baseline alignment important

Setup and Generation

Install Fanticon

npm install --save-dev fanticon

Configuration

// scripts/generate-icon-font.js
const fanticon = require('fanticon');
 
fanticon({
  inputDir: './assets/icons/optimized',
  outputDir: './assets/fonts',
  fontTypes: ['ttf', 'woff', 'woff2'],
  assetTypes: ['ts', 'json'],
  name: 'CustomIcons',
  prefix: 'icon',
  selector: '.icon',
  formatOptions: {
    json: {
      indent: 2
    }
  },
  pathOptions: {
    ts: './ui/foundation/icons/custom/font-mapping.ts'
  }
});

Usage with Icon Fonts

// ui/foundation/icons/custom/IconFont.tsx
import { createIconSetFromFontello } from '@expo/vector-icons';
import fontMapping from './font-mapping';
 
const CustomIconFont = createIconSetFromFontello(
  fontMapping,
  'CustomIcons',
  'CustomIcons.ttf'
);
 
export default CustomIconFont;

Performance Optimization

Bundle Size Management

// ✅ GOOD: Import specific icons only
import { ArrowLeftIcon, MenuIcon } from '@/ui/foundation/icons/custom';
 
// ❌ BAD: Import all custom icons
import * as CustomIcons from '@/ui/foundation/icons/custom';

Lazy Loading Pattern

// Lazy load custom icons for better performance
const LazyCustomIcon: React.FC<LazyIconProps> = ({ name, ...props }) => {
  const [IconComponent, setIconComponent] = useState<React.ComponentType | null>(null);
  
  useEffect(() => {
    const loadIcon = async () => {
      try {
        const { [name]: Component } = await import('@/ui/foundation/icons/custom');
        setIconComponent(() => Component);
      } catch (error) {
        console.warn(`Failed to load custom icon: ${name}`);
      }
    };
    
    loadIcon();
  }, [name]);
  
  if (!IconComponent) {
    return <View style={{ width: props.size, height: props.size }} />;
  }
  
  return <IconComponent {...props} />;
};

Testing Custom Icons

Component Tests

// __tests__/CustomIcon.test.tsx
import React from 'react';
import { render } from '@testing-library/react-native';
import { Icon } from '../Icon';
 
describe('Custom Icon Component', () => {
  it('renders custom icons correctly', () => {
    const { getByTestId } = render(
      <Icon name="arrow-left" library="custom" testID="custom-icon" />
    );
    
    expect(getByTestId('custom-icon')).toBeTruthy();
  });
  
  it('applies custom size and color', () => {
    const { getByTestId } = render(
      <Icon 
        name="menu" 
        library="custom" 
        size={32} 
        color="red" 
        testID="menu-icon" 
      />
    );
    
    const icon = getByTestId('menu-icon');
    expect(icon.props.size).toBe(32);
    expect(icon.props.color).toBe('red');
  });
  
  it('handles missing custom icons gracefully', () => {
    const { container } = render(
      <Icon name="non-existent" library="custom" />
    );
    
    // Should not crash and render nothing
    expect(container.children).toHaveLength(0);
  });
});

Icon Processing Tests

// __tests__/icon-processing.test.js
const fs = require('fs');
const path = require('path');
 
describe('Icon Processing Pipeline', () => {
  const customIconsDir = path.join(__dirname, '../ui/foundation/icons/custom');
  
  it('generates proper TypeScript components', () => {
    const files = fs.readdirSync(customIconsDir)
      .filter(file => file.endsWith('.tsx'));
    
    expect(files.length).toBeGreaterThan(0);
    
    files.forEach(file => {
      const content = fs.readFileSync(path.join(customIconsDir, file), 'utf8');
      expect(content).toContain('interface');
      expect(content).toContain('size?:');
      expect(content).toContain('color?:');
    });
  });
  
  it('generates proper barrel exports', () => {
    const indexFile = path.join(customIconsDir, 'index.ts');
    const content = fs.readFileSync(indexFile, 'utf8');
    
    expect(content).toContain('export { default as');
    expect(content).toContain('CustomIconName');
    expect(content).toContain('customIcons');
  });
});

Troubleshooting

Common Issues

SVGR not generating proper components:

  • Check SVGR configuration in svgr.config.js
  • Ensure SVG files are properly optimized
  • Verify template syntax is correct

Icons not displaying:

  • Check if react-native-svg is properly installed
  • Verify component imports are correct
  • Ensure SVG viewBox is preserved

Type errors:

  • Regenerate barrel exports after adding new icons
  • Check TypeScript definitions in registry
  • Verify icon names match file names

Platform-Specific Issues

Metro bundler errors:

  • Add SVG extension to Metro configuration
  • Clear Metro cache: npx react-native start --reset-cache

Build failures:

  • Ensure all SVG files are valid
  • Check for unsupported SVG features
  • Verify SVGR output is valid TypeScript