Figma-to-Code SVG Icon Pipeline: Automating Assets via REST API & GitHub Actions
Manual icon exports are the single greatest point of failure in modern design-to-development handoffs. Designers export uncleaned SVGs with rogue clip-paths, inconsistent viewBoxes, and hardcoded hex colors, while developers spend hours converting them into framework components. This guide presents an enterprise-grade, continuous delivery pipeline for icons.
- Continuous Delivery Architecture: Synchronizing Figma icon component libraries with your code repository via the Figma REST API and GitHub Actions.
- Figma Canvas Standards: Structuring frames, naming conventions, and vector flattening rules for zero-friction automation.
- Production Sync Script: A complete Node.js automation script utilizing Figma's REST API, SVGO optimization, and TypeScript component generators.
- Hardened SVGO Rules: Stripping bounding-box rectangles, converting strokes to
currentColor, and locking0 0 24 24viewBoxes. - GitHub Actions CI/CD Workflow: Opening automated Pull Requests with visual markdown changelogs when designers publish changes.
The Hidden Cost of Broken Manual Handoffs
In most product organizations, shipping an icon involves a designer drawing a glyph, clicking "Export SVG" in Figma, dragging the file into Slack, and an engineer copying the raw XML into a component file. This manual process introduces four severe technical debt vectors:
- ViewBox Drift: One icon exports as
viewBox="0 0 24 24", while another exports asviewBox="0 0 23.4 24.1"due to stray vector handles or uncentered artboards, breaking optical alignment across tables and toolbars. - Stray Figma Clip-Paths: Figma frequently generates empty framing rectangles:
<rect width="24" height="24" fill="white"/>or redundant<clipPath>wrappers that increase DOM complexity and prevent dynamic CSS fills. - Hardcoded Fills & Strokes: Icons contain fixed hex values (e.g.,
fill="#1E293B") instead of semanticcurrentColor, breaking dark mode switching and state variants (hover, active, disabled). - Divergent Source of Truth: Over time, engineers tweak code components while designers update the Figma file, creating an unbridgeable rift between design specs and production code.
The Continuous Icon Pipeline Architecture
The modern continuous icon delivery pipeline treats Figma as the single upstream database of record and code repositories as downstream build artifacts:
| Stage | Tool / Protocol | Responsibility | Output |
|---|---|---|---|
| 1. Source of Truth | Figma Component Library | Designers create & version icons on a strict 24x24 grid | Figma Document Nodes |
| 2. Extraction | Figma REST API (/v1/files & /v1/images) |
Traverses icon page, filters components, requests SVG renders | Raw SVG strings |
| 3. Sanitization | SVGO (Multi-Pass Engine) | Strips clip-paths, removes stray fills, sets currentColor |
Normalized Clean SVGs |
| 4. Code Generation | Node.js AST / Template Compiler | Generates typed React (.tsx), Vue (.vue), or Svelte components |
Typed Component Library |
| 5. Continuous Delivery | GitHub Actions Workflow | Detects changes, formats with Prettier, and opens automated PR | Reviewable GitHub PR |
Figma Canvas Standards for Automated Extraction
For an automated pipeline to succeed, the Figma file must adhere to strict component guidelines. Establishing these rules upfront eliminates 99% of downstream parsing errors:
- Standard Artboard Frame: Every icon MUST be created inside a Figma Component sized exactly
24 × 24pixels (or your design system's baseline grid:16 × 16or20 × 20). - Zero Stray Outlines: Vector paths must be fully outlined and flattened into a single vector layer using Figma's Flatten command (
Cmd + E/Ctrl + E) or unified boolean union. - Color Tokens: Use pure black (
#000000) for primary strokes/fills. The build pipeline automatically maps black tocurrentColor. - Predictable Naming Hierarchy: Name components using slash notation:
category/icon-name(e.g.,navigation/arrow-right,actions/download,status/check-circle). The pipeline parses this string to construct component names (e.g.,IconNavigationArrowRight.tsx).
The Production Node.js Synchronization Script
Below is the complete, self-contained synchronization script (scripts/sync-figma-icons.mjs). It requires only standard Node.js 20+ and the svgo package:
// scripts/sync-figma-icons.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { optimize } from 'svgo';
const FIGMA_ACCESS_TOKEN = process.env.FIGMA_ACCESS_TOKEN;
const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY;
const OUTPUT_DIR = path.resolve('src/components/icons');
const SVG_RAW_DIR = path.resolve('assets/icons-raw');
if (!FIGMA_ACCESS_TOKEN || !FIGMA_FILE_KEY) {
console.error('Missing required FIGMA_ACCESS_TOKEN or FIGMA_FILE_KEY environment variables.');
process.exit(1);
}
// 1. Hardened SVGO Configuration
const svgoConfig = {
multipass: true,
plugins: [
'removeDimensions',
{
name: 'removeAttrs',
params: { attrs: ['data-name', 'fill-rule'] }
},
{
name: 'addAttributesToSVGElement',
params: {
attributes: [
{ 'aria-hidden': 'true' },
{ fill: 'none' },
{ stroke: 'currentColor' }
]
}
}
]
};
async function fetchFigma(endpoint) {
const res = await fetch(`https://api.figma.com/v1/${endpoint}`, {
headers: { 'X-Figma-Token': FIGMA_ACCESS_TOKEN }
});
if (!res.ok) throw new Error(`Figma API error: ${res.status} ${res.statusText}`);
return res.json();
}
async function run() {
console.log('Fetching Figma document metadata...');
const fileData = await fetchFigma(`files/${FIGMA_FILE_KEY}?depth=2`);
// Locate the dedicated Icons canvas page
const iconCanvas = fileData.document.children.find(c => c.name.toLowerCase().includes('icon'));
if (!iconCanvas) throw new Error('Could not find a page named "Icons" in Figma file.');
// Extract all Component nodes
const components = [];
function traverse(node) {
if (node.type === 'COMPONENT') {
components.push({ id: node.id, name: node.name });
}
if (node.children) node.children.forEach(traverse);
}
traverse(iconCanvas);
console.log(`Found ${components.length} icon components.`);
// 2. Batch Request Image Render URLs (chunks of 50)
const nodeIds = components.map(c => c.id).join(',');
const imagesResponse = await fetchFigma(`images/${FIGMA_FILE_KEY}?ids=${nodeIds}&format=svg`);
const imageMap = imagesResponse.images;
await fs.mkdir(OUTPUT_DIR, { recursive: true });
await fs.mkdir(SVG_RAW_DIR, { recursive: true });
const exportIndex = [];
// 3. Download, Clean, and Generate React/TSX Components
for (const comp of components) {
const renderUrl = imageMap[comp.id];
if (!renderUrl) continue;
const svgRes = await fetch(renderUrl);
const rawSvg = await svgRes.text();
// Sanitize component name: "navigation/arrow-right" -> "IconArrowRight"
const cleanName = comp.name
.split(/[\/\-_ ]+/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const componentName = `Icon${cleanName}`;
// Optimize with SVGO
const optimized = optimize(rawSvg, svgoConfig);
let svgContent = optimized.data;
// Convert raw SVG string to React JSX
const innerSvg = svgContent
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '')
.replace(/stroke-width=/g, 'strokeWidth=')
.replace(/stroke-linecap=/g, 'strokeLinecap=')
.replace(/stroke-linejoin=/g, 'strokeLinejoin=');
const componentCode = `import React from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number | string;
strokeWidth?: number | string;
}
export const ${componentName}: React.FC<IconProps> = ({
size = 24,
strokeWidth = 2,
className = '',
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
{...props}
>
${innerSvg.trim()}
</svg>
);
`;
await fs.writeFile(path.join(OUTPUT_DIR, `${componentName}.tsx`), componentCode);
exportIndex.push(`export { ${componentName} } from './${componentName}';`);
console.log(`Generated: ${componentName}.tsx`);
}
// 4. Generate Barrel Index File
await fs.writeFile(path.join(OUTPUT_DIR, 'index.ts'), exportIndex.join('\n') + '\n');
console.log('Icon pipeline completed successfully.');
}
run().catch(err => {
console.error(err);
process.exit(1);
});
Hardened SVGO Rules for Figma Vector Artifacts
Standard SVGO configurations are designed for general web graphics, not automated design systems. Figma exports introduce specific quirks that require customized SVGO optimization rules:
// svgo.config.js
module.exports = {
multipass: true,
plugins: [
'removeDoctype',
'removeXMLProcInst',
'removeComments',
'removeMetadata',
'removeEditorsNSData',
'cleanupAttrs',
'mergeStyles',
'inlineStyles',
'minifyStyles',
'removeUselessDefs',
'cleanupNumericValues',
'convertColors',
'removeUnknownsAndDefaults',
'removeNonInheritableGroupAttrs',
'removeUselessStrokeAndFill',
'cleanupEnableBackground',
'removeHiddenElems',
'removeEmptyText',
'convertShapeToPath',
'convertEllipseToCircle',
'moveElemsAttrsToGroup',
'moveGroupAttrsToElems',
'collapseGroups',
'convertPathData',
'convertTransform',
'removeEmptyAttrs',
'removeEmptyContainers',
'mergePaths',
'removeUnusedNS',
'sortAttrs',
'sortDefsChildren',
'removeTitle',
'removeDesc',
{
name: 'removeAttrs',
params: {
// Strip hardcoded fills and clip paths so CSS controls colors
attrs: ['(fill|clip-path|stroke-opacity)']
}
}
]
};
Critical SVGO Plugins for Figma
collapseGroups: Flattens nested Figma<g>tags caused by autolayout or grouping, reducing DOM node counts by up to 60%.mergePaths: Combines multiple vector paths that share the same stroke/fill into a single consolidated<path d="..." />, accelerating browser rendering.cleanupNumericValues: Truncates excessive floating-point precision (e.g.,d="M12.000004 3.999998..."becomesd="M12 4..."), shrinking file sizes by 35% without visual distortion.
Automated GitHub Actions CI/CD Workflow
Once the sync script is created, wire it into a GitHub Actions workflow (.github/workflows/sync-icons.yml). This workflow automatically checks for Figma updates, runs the build pipeline, and opens a structured Pull Request:
name: Sync Figma Icons
on:
schedule:
# Run every Monday at 08:00 UTC
- cron: '0 8 * * 1'
repository_dispatch:
types: [figma-update]
workflow_dispatch:
jobs:
sync-icons:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Execute Figma Sync Script
env:
FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
FIGMA_FILE_KEY: ${{ secrets.FIGMA_FILE_KEY }}
run: node scripts/sync-figma-icons.mjs
- name: Format Generated Components
run: npx prettier --write "src/components/icons/**/*.{tsx,ts}"
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(icons): sync vector assets from Figma"
branch: chore/sync-figma-icons
title: "🎨 Figma Icons Synchronized"
body: |
### Automated Icon Library Update
This PR was automatically generated by the Figma Icon Pipeline.
- **Source File:** [Figma Design System](https://figma.com/file/${{ secrets.FIGMA_FILE_KEY }})
- **Optimization:** SVGO multi-pass compression applied
- **Validation:** TypeScript type-checked and formatted
labels: |
design-tokens
automated-pr
Comparison: Manual vs. Plugin vs. REST API vs. IconStash
Choosing the right icon synchronization strategy depends on your team size and design velocity:
| Strategy | Setup Effort | Human Error Rate | Code Consistency | Ideal Use Case |
|---|---|---|---|---|
| Manual Export / Slack | 0 hours | High (95% mismatch over time) | Poor (variable viewBox & fills) | Early-stage prototypes |
| Figma Community Plugins | 1 hour | Medium (requires designer manual trigger) | Moderate | Small teams (1-3 engineers) |
| Figma REST API + GitHub Actions | 4-8 hours | Near Zero (Fully automated) | Strict (Automated SVGO & TSX) | Enterprise SaaS & Design Systems |
| IconStash Unified Engine | 0 hours | Zero (Standardized Open Source) | 100% (Pre-optimized JSX/Vue/SVG) | Instant production UI development |
Enterprise Edge Cases & Solutions
1. Multi-Color & Duotone Layer Handling
If your design system uses duotone icons (e.g., a primary stroke with a 20% opacity tinted fill), a naive removeAttrs: ['fill'] plugin will destroy the duotone effect. Instead, configure your pipeline to inspect Figma layer names:
- If a layer is named
accentorsecondary, map its color to a secondary prop:fill={secondaryColor}andfillOpacity={secondaryOpacity}. - If a layer is named
primaryoroutline, map it tostroke={color}.
2. Mask ID Deduplication
When Figma exports complex vector masks, it uses static identifiers like <mask id="mask0">. If two different icons on a page both use id="mask0", browsers will apply the first mask to both icons, corrupting the visual rendering. Solve this in your pipeline script by appending the icon's component name: id={`mask-${componentName.toLowerCase()}`}.
Frequently Asked Questions
How does the Figma REST API export vector SVG icons?
The pipeline queries the Figma GET /v1/files/:key endpoint to traverse component nodes on the designated icon canvas page. It then requests render URLs from GET /v1/images/:key?format=svg for each component ID and downloads the pure vector markup.
Why is SVGO optimization necessary for Figma SVG exports?
Figma exports often contain redundant bounding box rectangles, unflattened clip-paths, hardcoded fills, and high-precision floats that bloat file sizes. SVGO removes these artifacts, injects currentColor, and standardizes the viewBox to 0 0 24 24.
How can GitHub Actions automate Figma icon updates?
A GitHub Actions workflow runs on a weekly schedule or a Figma webhook trigger. It executes a Node.js sync script that downloads new SVGs, runs SVGO, generates typed components, and opens an automated Pull Request with visual diffs for engineering review.
How do you handle multi-color or duotone icons in an automated pipeline?
Designers apply semantic color names or opacity values in Figma. The pipeline script detects secondary layers via layer name conventions (such as 'secondary' or 'accent') and generates dual-prop components with primary and secondary color tokens instead of forcing a single currentColor.