Syncing Figma Variables and Style Dictionary with GitHub Actions
Overview
Figma recently announced its new Variables product, allowing you to define and manage your design tokens directly within Figma. I have been experimenting with Figma Variables for a few weeks and am reasonably impressed with how it works. In this article, I'll share how I've been using Figma Variables to support my design projects and how I've been syncing them with my codebase with GitHub Actions and StyleDictionary, utilising a dev preview of the Figma API. For this article, I’ll focus primarily on syncing these variables one way from Figma to GitHub, making Figma the source of truth for all my design tokens.
Required Tooling
Before we start, I'd like to quickly review the tools I'll use to sync my variables from Figma:
- Figma - Naturally, we'll need Figma. Sadly, you'll need the enterprise plan to access the API used in this article, as there are additional scopes required that the free program does not offer. Please change this Figma... 🙏
- StyleDictionary - StyleDictionary is a tool that allows you to transform your design tokens into various formats. We'll use this to convert the variables pulled from Figma to be used across different platforms. I'll use a fresh installation of StyleDictionary version
3.8.0
to keep things simple. - GitHub Actions - To sync the variables from Figma to GitHub, we'll use GitHub Actions to run automations. We can use this to run a workflow on a schedule that will automatically update our variables daily. If you don't have access to Actions, you can use any other CI/CD tool that supports running a script on a schedule.
Connecting to the API
The first thing we'll need to do is connect to the Figma Rest API. We'll need to create a new personal access token to do this with the file_variables:read
scope. You can accomplish this by visiting your account settings in Figma and clicking the "Personal Access Tokens" tab. From here, you'll want to click the "Create a new personal access token" button and give it a name. Ensure you note this value, as we'll be referencing it later.
With our personal access token in hand, we can make the following API request to get a list of tokens back from the Figma API. In the following example, you'll want to replace the fileKey
value with the file id from which you'd like to pull variables. You can find this value by going to the file in question and copying the id from the URL.
curl -H 'X-FIGMA-TOKEN: <personal access token>' 'https://api.figma.com/v1/files/:file_key/variables/local'
Below, you'll see what you get back from the Figma API. I've removed some of the values for brevity.
{
"meta": {
"variables": {
"VariableID:2:41": {
"id": "VariableID:2:41",
"name": "Primary",
"variableCollectionId": "VariableCollectionId:2:15",
"resolvedType": "COLOR",
"valuesByMode": {
"2:0": "00A4EF",
"2:1": "107C10"
},
"remote": false
},
"VariableID:2:40": {
"id": "VariableID:2:40",
"name": "Secondary",
"valuesByMode": {
"2:0": "F25022",
"2:1": "3A3A3A"
},
"variableCollectionId": "VariableCollectionId:2:15",
"resolvedType": "COLOR"
},
"VariableID:2:39": {
"id": "VariableID:2:41",
"name": "Tertiary",
"valuesByMode": {
"2:0": "7FBA00"
},
"variableCollectionId": "VariableCollectionId:2:15",
"resolvedType": "COLOR"
}
},
"variableCollections": {
"VariableCollectionId:2:15": {
"id": "VariableCollectionId:2:15",
"name": "Brand Colours",
"modes": [
{
"id": "2:0",
"name": "Microsoft"
},
{
"id": "2:1",
"name": "Xbox"
}
],
"defaultModeId": "2:0",
"remote": false
}
}
}
}
If we examine the payload closer, there are a few things to note here. Firstly, we have a grouping called variableCollections
, which, as you guessed, contains our variable collections along with any associated modes. Modes represent different brands or themes, such as light or dark, within your design system. In this example, we have two modes, one for Microsoft and one for Xbox. We can see that the default mode is set to Microsoft as its id points to 2:0
.
For this example, we'll implement fallback behaviour, where if a variable is not defined for a given mode, we'll fall back to the default mode. This behaviour is helpful when a variable is shared across all modes, such as a primary or secondary colour. This may not be desired, however, depending on your use case, so adjust accordingly. You may want to instead throw an error if a variable is not defined for a given mode, or leave the variable undefined and provide a reasonable default in your applications.
GitHub Actions Workflow
Using GitHub Actions, we'll request this endpoint on a schedule and save its contents within our GitHub repository, where our StyleDictionary instance lives. If you're unfamiliar with GitHub Actions, check out my previous article, where I talk in detail about some of the actions we'll use in this example.
The gist of what's happening here is that we request the Figma API and then save the raw response to a file within the repository, in a file called figma.json
, within the data
directory. Later, we'll reference this file in a scheduled job to transform and build our design tokens for usage in their projects.
name: Figma Variables Sync
on:
schedule:
- cron: 10 15 * * 0-6 # Run every day at 3:10pm UTC
jobs:
refresh-feed:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Fetch Figma API Data 📦
uses: JamesIves/fetch-api-data-action@releases/v2
with:
endpoint: https://api.figma.com/v1/files/${{ secrets.FIGMA_FILE_ID }}/variables/local
configuration: '{ "method": "GET", "headers": {"X-FIGMA-TOKEN": "${{ secrets.FIGMA_PAT }}"} }'
save-name: figma
- name: Deploy Workspace Changes 🚀
uses: JamesIves/github-pages-deploy-action@releases/v4
with:
branch: main
folder: fetch-api-data-action
target-folder: data
Configuring Style Dictionary
At this stage in our workflow, we've fetched the raw response from the Figma API, so it's available within our codebase. However, we must still transform it into a format that StyleDictionary understands. Where this transformation takes place is up to you–this could happen in your actions workflow within a follow-up step, or you could transform it right within StyleDictionary. It's totally up to you. Doing whatever works on your existing workflows and patterns would be best.
Transforming the Data
For this example, we'll take care of this within StyleDictionary. Create a transform.js
file at the project's root and run it before StyleDictionary's build scripts execute. This transformer will standardise the format of our variables, removing any unnecessary data and flattening the structure. We'll also create a file for each mode and then a file for each resolvedType
within that mode.
For instance, if our Figma variable set included strings and colours, it will create a tokens/brands/{brand}/color.json
and tokens/brands/{brand}/string.json
file for each mode.
const inputData = require("./data/figma.json");
const outputDirectory = "./tokens/brands";
const createDirectory = async (dirPath) => {
try {
await fs.mkdir(dirPath, { recursive: true })
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
}
const transformFigmaVariables = async () => {
const transformedData = {}
/**
* Loop through each variable and mode and create a new object.
*/
Object.values(inputData.meta.variables).forEach((variable) => {
const { name, valuesByMode, variableCollectionId, resolvedType } = variable
const { defaultModeId } =
inputData.meta.variableCollections[variableCollectionId]
const defaultModeValue = valuesByMode && valuesByMode[defaultModeId]
/**
* Group variables by resolved type.
*/
Object.values(
inputData.meta.variableCollections[variableCollectionId].modes,
).forEach((mode) => {
const { id: modeId, name: modeName } = mode
const modeValue = valuesByMode && valuesByMode[modeId]
if (!transformedData[modeName]) {
transformedData[modeName] = {}
}
if (!transformedData[modeName][resolvedType]) {
transformedData[modeName][resolvedType] = {}
}
/**
* If a variable is not defined for a given mode, we'll fall back to the default mode.
*/
transformedData[modeName][resolvedType][name.toLowerCase()] = {
value: modeValue || defaultModeValue,
}
})
})
/**
* Generates files for each mode and type.
*/
await Promise.all(
Object.entries(transformedData).map(async ([modeName, modeData]) => {
const sanitizedModeName = modeName.toLowerCase().replace(/\s+/g, '-')
const modeDirectory = path.join(outputDirectory, sanitizedModeName)
await createDirectory(modeDirectory)
await Promise.all(
Object.entries(modeData).map(async ([resolvedType, data]) => {
const resolvedTypeTitle = resolvedType.toLowerCase()
const outputFilePath = path.join(
modeDirectory,
`${resolvedTypeTitle}.json`,
)
await fs.writeFile(
outputFilePath,
JSON.stringify({
[resolvedTypeTitle]: { ...data },
}),
)
}),
)
}),
)
}
By all means, this script isn't full-proof, but it gets the job done. Given our payload example above, it created two sets of variables–one located within tokens/brands/microsoft/color.json
, which looks like the following:
{
"color": {
"primary": {
"value": "00A4EF"
},
"secondary": {
"value": "F25022"
},
"tertiary": {
"value": "7FBA00"
}
}
}
And another within tokens/brands/xbox/color.json
. In this example, as Xbox doesn't have a tertiary colour, it falls back to the tertiary colour of the default mode, which is the Microsoft brand. While the Figma UI may
require you to have a value for each mode, it's not required when defining variables via the API, which is how you may get in this state.
{
"color": {
"primary": {
"value": "107C10"
},
"secondary": {
"value": "3A3A3A"
},
"tertiary": {
"value": "7FBA00"
}
}
}
Defining the Platforms
At this point, we need to define the platforms we want to make our variables available to by extending build.js
that comes with StyleDictionary. I'll be compiling web/css
for this example, but you can extend the config as much as you need.
The real joy of StyleDictionary is that you can define platforms for as many formats as you want. For instance, if you maintain a website alongside a native mobile app, you can source your design tokens from the same place and compile them into the formats you need for both.
/**
* @see - https://amzn.github.io/style-dictionary/#/config?id=platform
*/
function getStyleDictionaryConfig(brand) {
return {
source: [`tokens/brands/${brand}/*.json`, 'tokens/globals/**/*.json'],
platforms: {
/**
* Available platforms: https://amzn.github.io/style-dictionary/#/config?id=platform
*/
web: {
transformGroup: 'web',
buildPath: `build/web/${brand}/`,
files: [
{
destination: 'tokens.scss',
format: 'scss/variables',
},
],
},
},
}
}
/**
* Define the brands you want to build.
* These should match the names of your Figma modes.
*/
const brands = ['microsoft', 'xbox']
/**
* Define the platforms you want to build.
*/
const platforms = ['web']
/**
* Build the tokens for each brand.
* {@see - Example based on https://github.com/amzn/style-dictionary/tree/main/examples/advanced/multi-brand-multi-platform}
*/
brands.map(function (brand) {
platforms.map(function (platform) {
const StyleDictionary = StyleDictionaryPackage.extend(
getStyleDictionaryConfig(brand),
)
StyleDictionary.buildPlatform(platform)
})
})
Once the npm run build
script has run, you should see all of the files you need.
:root {
--color-primary: #00a4ef;
--color-secondary: #f25022;
--color-tertiary: #7fba00;
}
:root {
--color-primary: #107c10;
--color-secondary: #3a3a3a;
--color-tertiary: #7fba00;
}
Publishing the Variables
Once you have StyleDictionary exporting everything you need, the last thing to do is publish it to your chosen registry. I like to do this every time the nightly job runs to sync the variables if there is a change. This way, I can be sure that the latest variables are always available to my team.
I have a dependent job that runs after the nightly sync that will publish the variables to a registry. Here's an example I put together using npm. You'll need to modify this based on the platform registry you're using.
name: Publish Variables
on:
workflow_run:
workflows: ["Figma Variables Sync"] # This must match the name of the workflow that syncs the variables
types:
- completed
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18.x'
registry-url: 'https://registry.npmjs.org'
- name: Configure Git
run: |
git config --global user.name 'Automated publish'
git config --global user.email '${{github.actor}}@users.noreply.github.com'
- name: Build StyleDictionary and Publish
if: ${{ github.event.workflow_run.conclusion == 'success' }} # Only run if the nightly sync was successful
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: |
npm version patch
git push && git push --tags
npm ci
npm run build
cp package.json ./build && cd build
npm publish
Now, my project just needs to install the published npm package and ingest the bundled CSS to switch between themes easily. With this configuration, if I update any of my variables in Figma, my front-end projects simply need to update their package version to get any changes populated automatically, keeping everything in sync with Figma. You could even get fancier with GitHub Actions here by configuring Dependabot to auto-merge any updates to the package and build on commit–but that's a topic for another day.
Conclusion
Hopefully, this helps you get started with the Figma Variables REST API. You may need to change some of the processes here depending on how you or your team works, but that’s the nature of the game when it comes to design systems. Remember that Figma Variables is still in beta, so much of this article is highly susceptible to change.
Additionally StyleDictionary can be useed for so much more, and I highly recommend you check out the StyleDictionary documentation to see what else it can do. I've only scratched the surface here, but it's a great tool to have in your arsenal when it comes to working with design systems.
Related Reading
- Engineering Design Systems in 2022
- Let's Chat About Design System Tokens
- Creating the United Income Component Library
Thank you once again to Melissa Eckels for the help with editing this article!