The merge conflict that broke staging
I opened GitHub last Tuesday and saw a massive red error on our main deployment pipeline. Staging was completely broken. Two different frontend teams had tried to update our Tailwind configuration file at the exact same time.
Team A added new spacing utilities for a marketing landing page. Team B added custom brand colours for a new product dashboard. Git got confused during the merge. Someone resolved the conflict manually and force-pushed the changes. We lost half the core brand colours and production deployments ground to a halt.
This happens all the time when a single configuration file becomes a bottleneck. Developers manually copy hex codes and pixel values from Figma. They paste them into a massive JavaScript object. Someone makes a typo. Someone else accidentally deletes a bracket. It is a fragile process.
I realised we needed to remove humans from this specific loop. The source of truth for design decisions is Figma. The destination is Tailwind. The bridge between them should be code.
The structure of a proper token file
We decided to use the W3C Design Token format. It is a standard JSON structure that represents design decisions. It keeps everything predictable.
Here is what a raw token file looks like when exported properly.
{
"colors": {
"brand": {
"primary": {
"$value": "#2563eb",
"$type": "color"
},
"secondary": {
"$value": "#4f46e5",
"$type": "color"
}
},
"surface": {
"background": {
"$value": "#ffffff",
"$type": "color"
}
}
},
"spacing": {
"sm": {
"$value": "0.5rem",
"$type": "dimension"
},
"md": {
"$value": "1rem",
"$type": "dimension"
},
"lg": {
"$value": "1.5rem",
"$type": "dimension"
}
}
}
Notice how clean that is. Every token has a value and a explicit type. We just need to transform this nested JSON into a flat object that Tailwind understands.
Writing the conversion script
We wrote a small TypeScript utility to handle the translation. We wanted it to run automatically in our CI pipeline. The script reads the JSON tokens and outputs a valid Tailwind preset.
Here is the exact code we use. I kept it simple and focused.
import fs from 'fs'
import path from 'path'
interface TokenNode {
$value?: string
$type?: string
[key: string]: any
}
function flattenTokens(obj: TokenNode, prefix = ''): Record<string, string> {
let result: Record<string, string> = {}
for (const key in obj) {
if (key.startsWith('$')) continue
const node = obj[key]
const newPrefix = prefix ? `${prefix}-${key}` : key
if (node.$value !== undefined) {
result[newPrefix] = node.$value
} else if (typeof node === 'object') {
const nested = flattenTokens(node, newPrefix)
result = { ...result, ...nested }
}
}
return result
}
function generateTailwindTheme() {
const rawData = fs.readFileSync(path.join(__dirname, 'tokens.json'), 'utf8')
const tokens = JSON.parse(rawData)
const colors = flattenTokens(tokens.colors || {})
const spacing = flattenTokens(tokens.spacing || {})
const tailwindConfig = {
theme: {
extend: {
colors,
spacing
}
}
}
const fileContent = `export default ${JSON.stringify(tailwindConfig, null, 2)}`
fs.writeFileSync(path.join(__dirname, 'tailwind.preset.js'), fileContent)
console.log('Tailwind preset generated successfully')
}
generateTailwindTheme()
This script recurses through the W3C token tree. It ignores the metadata keys that start with a dollar sign. It builds flat strings like brand-primary and assigns the hex code. Finally it writes a complete JavaScript module to the disk.
You do not need to touch your main Tailwind config anymore. You just import this generated preset.
import designSystemPreset from './tailwind.preset.js'
export default {
presets: [designSystemPreset],
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
Setting up the GitHub Action
A script is useless if you have to remember to run it. We needed this to happen automatically whenever design tokens changed.
We set up a GitHub Action. It listens for changes to our token file. When a pull request updates the JSON data the action runs our conversion script. It then commits the updated Tailwind preset right back to the same pull request.
Here is the workflow file we built.
name: Sync Tailwind Preset
on:
pull_request:
paths:
- 'tokens.json'
jobs:
build-preset:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Generate Preset
run: npx ts-node scripts/generate-preset.ts
- name: Commit changes
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add tailwind.preset.js
git commit -m "chore: update tailwind preset from tokens" || echo "No changes to commit"
git push
Now the process is entirely hands off. A designer updates a variable in Figma. A pull request is created with the new JSON. GitHub Actions spots the change and regenerates the Tailwind preset. The developers just review the code and click merge. No more merge conflicts. No more manual data entry.
Letting designers own the source of truth
Building this pipeline from scratch was a fun weekend project. But maintaining custom conversion scripts gets tedious when you start dealing with complex typography rules or multiple dark mode themes.
That is exactly why I built Design System Sync. It handles this entire workflow out of the box. You run the plugin inside Figma. It exports your variables and styles directly to GitHub or Bitbucket. It automatically creates the pull request with a visual diff. It supports W3C Design Tokens and CSS Variables natively.
If you want to skip writing custom token parsers you can check out the product page at https://ds-sync.netlify.app?utm_source=devto&utm_medium=post&utm_campaign=bot. You can also grab the plugin directly from the Figma Community. The free tier gives you five exports a month which is plenty for testing out a new workflow.
Stop manually copying hex codes. Let the machines do the boring work so you can focus on building actual features.
Top comments (0)