Una migración Figma→producción es un problema de contrato antes que un problema de CSS. El archivo de Figma expresa intent de diseño; el código expresa comportamiento en runtime; el hueco entre los dos es donde viven los bugs. Lo cerramos haciendo que los mismos datos alimenten ambos: las Figma Variables pasan a ser los tokens CSS, los componentes de Figma pasan a ser los componentes React, y la visual regression mantiene la línea donde la intuición de otro modo derivaría.
Los cinco archivos de abajo componen una pipeline que convierte un archivo de Figma bien auditado en una librería de componentes tipada. El exporter de variables, el transformador de tokens, el contrato de componente, la implementación React, y el test de visual regression.
1. El exporter de Figma Variables
Figma expone las Variables en GET /v1/files/:file_key/variables/local. El exporter se autentica con un personal access token (o, en plan team, uno emitido por OAuth), tira de cada colección de variables y cada modo, y escribe un JSON plano en disco. El transformador aguas abajo consume ese 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. El transformador de tokens
El transformador convierte el JSON de Figma en dos artefactos: un archivo CSS con custom properties --ds-* (una regla por modo, light y dark), y un módulo TypeScript que re-exporta los mismos valores. El CSS es lo que el runtime lee; el TypeScript es lo que importa el JSX cuando un token tiene que aparecer en lógica 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. El contrato de componente
Cada componente Figma vive en MDX al lado del archivo React. El contrato es la fuente de verdad: diseñador y desarrollador lo leen y discrepan explícitamente en la code review de la PR, no implícitamente por inspección.
{/* src/components/Button/Button.spec.mdx */}
---
component: Button
figma: https://figma.com/file/abc/Adamarant?node-id=12:34
---
# Button
Elemento interactivo primario. Renderiza un `<button>` semánticamente;
pasar `as="a"` para renderizar un anchor manteniendo la API visual idéntica.
## Props
| Prop | Type | Default | Notas |
|-----------|-----------------------------------------------|-----------|-------|
| `variant` | `'primary' \| 'ghost' \| 'danger'` | `primary` | Mapea la Figma Variant `Variant=primary/ghost/danger` |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Mapea la Figma Variant `Size=sm/md/lg` |
| `state` | `'default' \| 'hover' \| 'active' \| 'disabled'` | derivado | CSS-driven; el frame Figma `State=hover` es solo para documentación |
| `as` | `'button' \| 'a'` | `button` | Cuando es `'a'`, requiere `href` |
| `loading` | `boolean` | `false` | Reemplaza el label por un spinner; deshabilita el click |
## Accesibilidad
- `aria-busy` puesto cuando `loading`
- Focus ring usa `--ds-focus-ring`, nunca sobrescrito por proyecto
- Target táctil ≥ 44 px en `size=sm` vía padding
## Referencia visual
Embebido desde Figma: la matriz de variantes muestra primary/ghost/danger × sm/md/lg = 9 frames.
4. La implementación React
El componente lee los tokens del CSS; ningún valor está hardcoded. Las props espejan la API de Figma; el desarrollador puede responder "¿cómo se ve Variant=ghost?" leyendo el código, no el archivo.
// 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. El test de visual regression
Playwright renderiza cada variante contra un baseline almacenado. El test falla cuando el diff supera el umbral por componente. El umbral es pequeño (típicamente 3 a 5 píxeles) y vive en el archivo de test; subirlo es una conversación de PR, no un cambio silencioso.
// 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. Cómo encaja todo esto
El exporter tira las Figma Variables. El transformador escribe tokens CSS y exports TypeScript. El contrato pone nombre a cada componente y sus props. La implementación lee tokens, no valores hex. La suite de visual regression atrapa la divergencia antes de que salga.
El archivo de Figma deja de ser un documento de discusión y pasa a ser un contrato que el codebase honra. Los diseñadores ven sus decisiones llegar a producción; los desarrolladores dejan de adivinar valores de padding. El handoff desaparece como reunión de estado porque ahora el handoff es una pull request: un diff de tokens, una actualización de contrato, un delta de visual regression. El siguiente cambio de diseño se mueve por la misma pipeline; el proyecto no paga el impuesto de handoff dos veces.