Building an Automated Figma-to-Tailwind v4 Token Pipeline for Zero Design Drift
How our design engineering squad syncs Figma Variables to Tailwind CSS v4 theme tokens using automated GitHub Actions and Playwright visual regression tests.
How our design engineering squad syncs Figma Variables to Tailwind CSS v4 theme tokens using automated GitHub Actions and Playwright visual regression tests.
A design token sync pipeline is an automated CI/CD workflow that extracts color palettes, typography scales, spacing units, and radius values from Figma design files and compiles them into production-ready CSS variables and TypeScript constants. When a designer publishes updated variables in Figma, a webhook triggers a GitHub Action that pulls the raw JSON tokens, transforms them into Tailwind CSS v4 @theme definitions, and opens an automated pull request with visual regression screenshots.
[Designer Updates Figma Variables] β βΌ [Figma REST API Token Export] β βΌ [Node.js Token Transformation Script] β ββββΊ [Tailwind v4 CSS: @theme inline] ββββΊ [TypeScript Constants: tokens.ts] β βΌ[Playwright VRT (Visual Regression Testing)] β βΌ [Automated GitHub PR with Diff Images]The most frequent source of friction between product designers and frontend engineers is visual drift. Designers build in Figma using structured variable collections for semantic colors, elevation levels, and typography presets. Engineers inspect the file, manually copy hex values into CSS files, and inevitably introduce subtle discrepancies in spacing and contrast.
Here is the exact pipeline our studio uses across every client build to guarantee pixel-for-pixel accuracy between Figma and production code.
Tailwind CSS v4 moved away from JavaScript-heavy tailwind.config.js files in favor of CSS-first @theme blocks. This transition makes design token injection significantly faster and eliminates the need to compile complex configuration objects at runtime.
Instead of writing custom Tailwind plugin functions to register semantic color variables, you define your design tokens directly inside your main CSS entry point:
@theme inline { --color-brand-primary: var(--primary); --color-brand-accent: var(--electric-blue); --color-surface-card: var(--card); --color-surface-border: var(--border);
--font-mono: "Geist Mono", monospace; --font-serif: "Newsreader", serif;
--spacing-section-gap: 4rem; --radius-button: 0px;}Because these variables are bound directly to standard CSS custom properties, switching between light and dark modes requires zero JavaScript recalculations. You simply toggle a [data-theme='dark'] attribute on the root document.
We write our extraction scripts in TypeScript using the official Figma REST API. The script fetches the fileβs variable collections, extracts modes (e.g., Light, Dark, High Contrast), and writes clean CSS and TypeScript output.
import fs from "node:fs/promises";import path from "node:path";
interface FigmaVariable { id: string; name: string; resolvedType: "COLOR" | "FLOAT" | "STRING"; valuesByMode: Record<string, any>;}
export async function exportFigmaTokens(fileKey: string, apiKey: string) { const res = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables/local`, { headers: { "X-Figma-Token": apiKey }, });
if (!res.ok) { throw new Error(`Failed to fetch Figma variables: ${res.statusText}`); }
const { meta } = await res.json(); const variables: Record<string, FigmaVariable> = meta.variables;
let lightCss = ":root, :root[data-theme='light'] {\n"; let darkCss = ":root[data-theme='dark'], .dark {\n";
for (const [, variable] of Object.entries(variables)) { if (variable.resolvedType === "COLOR") { const varName = variable.name.replace(/\//g, "-").toLowerCase();
// Mode 1: Light Theme const lightVal = formatRgba(variable.valuesByMode["mode_light_id"]); lightCss += ` --${varName}: ${lightVal};\n`;
// Mode 2: Dark Theme const darkVal = formatRgba(variable.valuesByMode["mode_dark_id"]); darkCss += ` --${varName}: ${darkVal};\n`; } }
lightCss += "}\n"; darkCss += "}\n";
const outputPath = path.join(process.cwd(), "src/styles/tokens.generated.css"); await fs.writeFile(outputPath, `${lightCss}\n${darkCss}`, "utf-8"); console.log("Tokens exported successfully to src/styles/tokens.generated.css");}
function formatRgba(color: { r: number; g: number; b: number; a: number }) { if (!color) return "#000000"; const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); const a = color.a !== undefined ? color.a : 1; return `rgba(${r}, ${g}, ${b}, ${a})`;}Automating token exports is only half the battle. You also need an automated gate to ensure that updated tokens do not break existing components or cause contrast regressions.
We run a Playwright Visual Regression Testing (VRT) suite in our GitHub Actions pipeline on every token update:
import { test, expect } from "@playwright/test";
test.describe("Design Token Visual Regression Suite", () => { const routes = ["/", "/pricing", "/blog", "/docs"];
for (const route of routes) { test(`Visual parity on ${route} [Light Mode]`, async ({ page }) => { await page.goto(route); await page.evaluate(() => document.documentElement.setAttribute("data-theme", "light")); await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${route.replace(/\//g, "-")}-light.png`, { maxDiffPixelRatio: 0.01, }); });
test(`Visual parity on ${route} [Dark Mode]`, async ({ page }) => { await page.goto(route); await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark")); await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${route.replace(/\//g, "-")}-dark.png`, { maxDiffPixelRatio: 0.01, }); }); }});border-radius: 0px for our brutalist blueprint layouts) directly inside the token compiler.