A Figma-to-production migration is a contract problem before it is a CSS problem. The Figma file expresses design intent; the code expresses runtime behaviour; the gap between them is where bugs live. We close the gap by making the same data feed both: Figma Variables become the CSS tokens, Figma components become the React components, and visual regression holds the line where intuition would otherwise drift.
The five files below compose a pipeline that turns a well-audited Figma file into a typed component library. The variable exporter, the token transformer, the component contract, the React implementation, and the visual regression test.
1. The Figma Variables exporter
Figma exposes Variables through GET /v1/files/:file_key/variables/local. The exporter authenticates with a personal access token (or, on a team plan, an OAuth-issued one), pulls every variable collection and mode, and writes a flat JSON to disk. The transformer downstream consumes that JSON.
// scripts/figma/export-variables.ts
import { writeFile } from 'node:fs/promises'
const FIGMA_TOKEN = process.env.FIGMA_TOKEN!
const FILE_KEY = process.env.FIGMA_FILE_KEY!
interface FigmaVariable {
id: string
name: string
resolvedType: 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
valuesByMode: Record<string, unknown>
}
interface FigmaVariablesResponse {
meta: {
variables: Record<string, FigmaVariable>
variableCollections: Record<string, { name: string; modes: { modeId: string; name: string }[] }>
}
}
async function main(): Promise<void> {
const res = await fetch(
`https://api.figma.com/v1/files/${FILE_KEY}/variables/local`,
{ headers: { 'X-Figma-Token': FIGMA_TOKEN } },
)
if (!res.ok) throw new Error(`figma: ${res.status}`)
const data = (await res.json()) as FigmaVariablesResponse
await writeFile(
'./data/figma-variables.json',
JSON.stringify(data.meta, null, 2),
'utf8',
)
console.log(
`exported ${Object.keys(data.meta.variables).length} variables across ${Object.keys(data.meta.variableCollections).length} collections`,
)
}
void main()
2. The token transformer
The transformer turns the Figma JSON into two artefacts: a CSS file with --ds-* custom properties (one rule per mode, light and dark), and a TypeScript module that re-exports the same values. The CSS is what the runtime reads; the TypeScript is what JSX imports when a token must appear in inline logic.
// scripts/figma/transform-tokens.ts
import { readFile, writeFile } from 'node:fs/promises'
interface VariableCollection {
name: string
modes: { modeId: string; name: string }[]
}
interface VariablesMeta {
variables: Record<string, {
name: string
resolvedType: 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
variableCollectionId: string
valuesByMode: Record<string, { r: number; g: number; b: number; a: number } | number | string | boolean>
}>
variableCollections: Record<string, VariableCollection>
}
function rgbaToHex(c: { r: number; g: number; b: number; a: number }): string {
const to255 = (n: number): number => Math.round(n * 255)
const hex = (n: number): string => n.toString(16).padStart(2, '0')
if (c.a < 1) return `rgba(${to255(c.r)}, ${to255(c.g)}, ${to255(c.b)}, ${c.a.toFixed(3)})`
return `#${hex(to255(c.r))}${hex(to255(c.g))}${hex(to255(c.b))}`
}
function toKebab(name: string): string {
return name
.replace(/\//g, '-')
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
}
async function main(): Promise<void> {
const meta = JSON.parse(await readFile('./data/figma-variables.json', 'utf8')) as VariablesMeta
const cssLines: string[] = []
for (const collection of Object.values(meta.variableCollections)) {
for (const mode of collection.modes) {
const selector = mode.name.toLowerCase() === 'dark' ? '[data-theme="dark"]' : ':root'
cssLines.push(`${selector} {`)
for (const v of Object.values(meta.variables)) {
if (v.variableCollectionId !== Object.entries(meta.variableCollections).find(([, c]) => c === collection)?.[0]) continue
const value = v.valuesByMode[mode.modeId]
if (value === undefined) continue
const tokenName = `--ds-${toKebab(v.name)}`
if (v.resolvedType === 'COLOR' && typeof value === 'object') {
cssLines.push(` ${tokenName}: ${rgbaToHex(value as { r: number; g: number; b: number; a: number })};`)
} else if (v.resolvedType === 'FLOAT' && typeof value === 'number') {
cssLines.push(` ${tokenName}: ${value}px;`)
}
}
cssLines.push('}', '')
}
}
await writeFile('./src/styles/tokens.generated.css', cssLines.join('\n'), 'utf8')
console.log(`wrote tokens.generated.css with ${cssLines.length} lines`)
}
void main()
3. The component contract
Each Figma component lives in MDX next to the React file. The contract is the source of truth: developer and designer both read it and disagree explicitly in PR review, not implicitly through inspection.
{/* src/components/Button/Button.spec.mdx */}
---
component: Button
figma: https://figma.com/file/abc/Adamarant?node-id=12:34
---
# Button
Primary interactive element. Renders a `<button>` semantically; pass
`as="a"` to render an anchor while keeping the visual API identical.
## Props
| Prop | Type | Default | Notes |
|-----------|-----------------------------------------------|-----------|-------|
| `variant` | `'primary' \| 'ghost' \| 'danger'` | `primary` | Maps to Figma Variant `Variant=primary/ghost/danger` |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Maps to Figma Variant `Size=sm/md/lg` |
| `state` | `'default' \| 'hover' \| 'active' \| 'disabled'` | derived | CSS-driven; Figma frame `State=hover` is for documentation only |
| `as` | `'button' \| 'a'` | `button` | When `'a'`, requires `href` |
| `loading` | `boolean` | `false` | Replaces label with spinner; disables click |
## Accessibility
- `aria-busy` set when `loading`
- Focus ring uses `--ds-focus-ring`, never overridden per project
- Touch target ≥ 44 px on `size=sm` via padding
## Visual reference
Embedded from Figma: Variant matrix shows primary/ghost/danger × sm/md/lg = 9 frames.
4. The React implementation
The component reads tokens from CSS; no value is hardcoded. Props mirror the Figma API; the developer can answer "what does Variant=ghost look like?" by reading the code, not the file.
// src/components/Button/Button.tsx
import { type ButtonHTMLAttributes, type AnchorHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
interface BaseProps {
variant?: 'primary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
children: React.ReactNode
}
type ButtonProps =
| (BaseProps & { as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
| (BaseProps & { as: 'a'; href: string } & AnchorHTMLAttributes<HTMLAnchorElement>)
export function Button(props: ButtonProps): React.ReactElement {
const { variant = 'primary', size = 'md', loading = false, children, className, ...rest } = props
const classes = cn(
'ds-btn',
`ds-btn--${variant}`,
`ds-btn--${size}`,
loading && 'ds-btn--loading',
className,
)
if (props.as === 'a') {
return (
<a
className={classes}
aria-busy={loading || undefined}
{...(rest as AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{loading ? <span className="ds-spinner" aria-hidden /> : children}
</a>
)
}
return (
<button
className={classes}
aria-busy={loading || undefined}
disabled={loading || (rest as ButtonHTMLAttributes<HTMLButtonElement>).disabled}
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{loading ? <span className="ds-spinner" aria-hidden /> : children}
</button>
)
}
5. The visual regression test
Playwright renders each variant against a stored baseline. The test fails when the diff exceeds the per-component threshold. The threshold is small (3 to 5 pixels typically) and lives in the test file; raising it is a PR conversation, not a silent change.
// tests/visual/Button.spec.ts
import { test, expect } from '@playwright/test'
const VARIANTS = ['primary', 'ghost', 'danger'] as const
const SIZES = ['sm', 'md', 'lg'] as const
for (const variant of VARIANTS) {
for (const size of SIZES) {
test(`Button variant=${variant} size=${size}`, async ({ page }) => {
await page.goto(`/storybook/?path=/story/button--${variant}-${size}`)
const button = page.locator('[data-testid="story-root"] .ds-btn')
await button.waitFor()
await expect(button).toHaveScreenshot(`button-${variant}-${size}.png`, {
maxDiffPixels: 4,
})
})
}
}
test('Button loading state', async ({ page }) => {
await page.goto('/storybook/?path=/story/button--loading')
const button = page.locator('[data-testid="story-root"] .ds-btn')
await button.waitFor()
await expect(button).toHaveScreenshot('button-loading.png', { maxDiffPixels: 4 })
})
6. What this composes
The exporter pulls Figma Variables. The transformer writes CSS tokens and TypeScript exports. The contract names every component and its props. The implementation reads tokens, not hex values. The visual regression suite catches drift before it ships.
The Figma file stops being a discussion document and becomes a contract the codebase honours. Designers see their decisions arrive in production; developers stop guessing at padding values. The handoff disappears as a status meeting because the handoff is now a pull request: a token diff, a contract update, a visual regression delta. The next design change moves through the same pipeline; the project does not pay the handoff tax twice.