Una migrazione Figma→produzione è un problema di contratto prima che un problema di CSS. Il file Figma esprime intent di design; il codice esprime comportamento a runtime; il buco tra i due è dove vivono i bug. Lo chiudiamo facendo leggere agli stessi dati entrambi: le Figma Variables diventano i token CSS, i componenti Figma diventano i componenti React, e la visual regression tiene la linea dove l'intuito altrimenti deriverebbe.
I cinque file qui sotto compongono una pipeline che trasforma un file Figma ben audited in una libreria di componenti tipizzata. L'exporter di variabili, il trasformatore di token, il contratto di componente, l'implementazione React, e il test di visual regression.
1. L'exporter Figma Variables
Figma espone le Variables su GET /v1/files/:file_key/variables/local. L'exporter si autentica con un personal access token (o, su team plan, uno emesso via OAuth), tira giù ogni collezione di variabili e modo, e scrive un JSON piatto su disco. Il trasformatore a valle consuma quel 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. Il trasformatore di token
Il trasformatore gira il JSON Figma in due manufatti: un file CSS con custom property --ds-* (una regola per modo, light e dark), e un modulo TypeScript che riesporta gli stessi valori. Il CSS è ciò che il runtime legge; il TypeScript è ciò che la JSX importa quando un token deve apparire in logica inline.
// 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. Il contratto di componente
Ogni componente Figma vive in MDX accanto al file React. Il contratto è la fonte di verità: designer e sviluppatore lo leggono entrambi e dissentono esplicitamente in code review della PR, non implicitamente tramite ispezione.
{/* src/components/Button/Button.spec.mdx */}
---
component: Button
figma: https://figma.com/file/abc/Adamarant?node-id=12:34
---
# Button
Elemento interattivo primario. Renderizza un `<button>` semanticamente;
passare `as="a"` per renderizzare un anchor mantenendo l'API visiva identica.
## Props
| Prop | Type | Default | Note |
|-----------|-----------------------------------------------|-----------|------|
| `variant` | `'primary' \| 'ghost' \| 'danger'` | `primary` | Mappa la Figma Variant `Variant=primary/ghost/danger` |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Mappa la Figma Variant `Size=sm/md/lg` |
| `state` | `'default' \| 'hover' \| 'active' \| 'disabled'` | derivato | CSS-driven; il frame Figma `State=hover` è solo per documentazione |
| `as` | `'button' \| 'a'` | `button` | Quando `'a'`, richiede `href` |
| `loading` | `boolean` | `false` | Sostituisce label con spinner; disabilita il click |
## Accessibilità
- `aria-busy` impostato quando `loading`
- Focus ring usa `--ds-focus-ring`, mai sovrascritto per progetto
- Target touch ≥ 44 px su `size=sm` via padding
## Riferimento visivo
Embedded da Figma: la matrice di varianti mostra primary/ghost/danger × sm/md/lg = 9 frame.
4. L'implementazione React
Il componente legge i token dal CSS; nessun valore è hardcoded. Le props rispecchiano l'API Figma; lo sviluppatore può rispondere a "com'è Variant=ghost?" leggendo il codice, non il 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. Il test di visual regression
Playwright renderizza ogni variante contro una baseline conservata. Il test fallisce quando il diff supera la soglia per componente. La soglia è piccola (3-5 pixel tipici) e vive nel file di test; alzarla è una conversazione di PR, non un cambio silenzioso.
// 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. Come si tiene insieme tutto questo
L'exporter tira giù le Figma Variables. Il trasformatore scrive token CSS ed export TypeScript. Il contratto nomina ogni componente e le sue props. L'implementazione legge i token, non valori hex. La suite di visual regression intercetta la divergenza prima che esca.
Il file Figma smette di essere un documento di discussione e diventa un contratto che il codebase onora. I designer vedono le loro decisioni arrivare in produzione; gli sviluppatori smettono di tirare a indovinare i valori di padding. L'handoff sparisce come riunione di stato perché l'handoff ora è una pull request: un diff di token, un aggiornamento di contratto, un delta di visual regression. Il prossimo cambio di design si muove nella stessa pipeline; il progetto non paga la tassa di handoff due volte.