Design tokens defined in Figma and consumed by Tailwind's CSS-based theming have been synced manually for a while now, a process reliable enough day to day but always one missed export away from drifting, usually discovered only when a designer noticed a color in production didn't quite match what they'd last set in Figma. We finally automated the whole thing end to end this quarter, after enough small drift incidents accumulated that the manual process stopped feeling like an acceptable steady state.
A scheduled job now pulls variable changes from Figma on a regular interval and opens a pull request updating the corresponding Tailwind theme file automatically, with a human still reviewing and merging rather than letting the change land unattended.
The generated theme file is a plain CSS `@theme` block:
@theme {
--color-brand-500: oklch(0.62 0.19 259);
--color-brand-600: oklch(0.54 0.2 259);
--spacing-section: 5rem;
--font-display: "Sohne", ui-sans-serif, system-ui;
}That review step matters more than it might sound like it should: an automated pull request makes an intentional design change from an accidental one, like someone nudging a spacing token while exploring an unrelated mockup, equally visible in a diff, and a human glancing at that diff before merging catches the accidental ones before they ship.
The scheduled job itself is a fairly ordinary cron-triggered CI workflow:
# .github/workflows/token-sync.yml
on:
schedule:
- cron: '0 9,13,17 * * 1-5'
jobs:
sync-tokens:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bunx figma-token-sync --file-key $FIGMA_FILE_KEY --out src/theme.css
- uses: peter-evans/create-pull-request@v6
with:
title: 'chore: sync design tokens from Figma'
branch: token-sync/autoRunning it three times a day on weekdays, rather than on every Figma change or on a single nightly batch, turned out to be the sweet spot between staying current and not asking engineers to review a token pull request every hour.
Drift between design and code has effectively stopped being a recurring complaint since this went live, which was the whole point, but a secondary benefit surprised us a little: the automated pull requests themselves have become a lightweight audit trail of design decisions over time, in a way the old manual export process never gave us. Scrolling back through merged token-sync pull requests shows a clean history of exactly when a given color or spacing value changed and why, tied to the Figma change that triggered it, which has been genuinely useful during a couple of "wait, when did this change" conversations that used to require someone's memory rather than a record.
A typical auto-generated pull request diff is small enough to review in seconds:
@theme {
- --color-brand-500: oklch(0.60 0.19 259);
+ --color-brand-500: oklch(0.62 0.19 259);
--spacing-section: 5rem;
}That kind of minimal, single-purpose diff is exactly what makes the review step cheap enough to keep mandatory rather than something engineers start rubber-stamping out of fatigue.
The one adjustment we made after the initial rollout was slowing the sync frequency down from near-real-time to a few times a day, after the first version generated enough small pull requests during active design exploration to feel noisy rather than helpful. Batching changes into a few daily syncs struck a better balance between staying current and not asking engineers to review a design-token pull request every hour.
We also had to think carefully about what counts as a token in the first place, since Figma's variable system is more flexible than Tailwind's theme structure and not every Figma variable maps cleanly onto a Tailwind token category. Composite variables that reference other variables, for instance, needed explicit handling in the translation layer rather than a naive one-to-one mapping, since a naive approach either flattened them into duplicated literal values or broke the reference chain entirely.
A composite variable comes back from Figma's API looking like this, referencing another variable by id rather than carrying a literal value:
{
"id": "VariableID:12:847",
"name": "color/brand/hover",
"resolvedType": "COLOR",
"valuesByMode": {
"1:0": { "type": "VARIABLE_ALIAS", "id": "VariableID:12:812" }
}
}The naive translation resolved `valuesByMode` at face value and stored the alias id as if it were a literal color, which is exactly the wrong-shade-on-the-homepage bug described above. The fix was walking the alias chain to its terminal literal value before ever writing a Tailwind token.
Getting that translation right took a few iterations early on, including one embarrassing week where a color alias silently resolved to the wrong value across the entire site because the translation layer picked the wrong variable in a reference chain, caught only because a designer happened to notice a slightly-off shade on the homepage.
That incident became the reason we added a validation step to the sync pipeline: before opening a pull request, the job renders a small visual diff of key components against both the old and new token values, attached directly to the pull request description, so a reviewing engineer can see at a glance whether a token change looks like what the designer actually intended rather than trying to mentally simulate the effect of a hex code change. That visual diff has caught more than one accidental variable reference since, well before it ever reached production.
The validation step that renders that visual diff is a small script run against both the pre- and post-sync theme files:
async function renderTokenDiff(oldTheme, newTheme) {
const changed = diffThemeValues(oldTheme, newTheme);
const shots = await Promise.all(
changed.map((token) => renderComponentsUsing(token.name))
);
return shots.map((s, i) => ({ token: changed[i].name, before: s.before, after: s.after }));
}Attaching the resulting before/after screenshots directly to the pull request means a reviewing engineer can confirm a token change looks right without opening Figma or mentally simulating what an `oklch` value shift actually looks like on a real button.