DEV Community

Cover image for Syncing Figma Variables and StyleDictionary with GitHub Actions
James Ives
James Ives

Posted on • Originally published at jamesiv.es

Syncing Figma Variables and StyleDictionary with GitHub Actions

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.

Figma Variables

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 1file_variables:read1 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.

Variables in Figma
Our variables waiting to be pulled from the API.

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'
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
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.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
Enter fullscreen mode Exit fullscreen mode

Remember you need to save any secrets within your repositories' secrets menu. Refer to the GitHub documentation for details on how to do this.

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 },
            }),
          )
        }),
      )
    }),
  )
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Defining the Platform

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.

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',
          },
        ],
      },
    },
  }
}

/**
 * Build the tokens for each brand.
 * {@see - Example based on https://github.com/amzn/style-dictionary/tree/main/examples/advanced/multi-brand-multi-platform}
 */
['microsoft', 'xbox'].map(function (brand) {
  ['web'].map(function (platform) {
    const StyleDictionary = StyleDictionaryPackage.extend(
      getStyleDictionaryConfig(brand),
    )

    StyleDictionary.buildPlatform(platform)
  })
})
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
:root {
  --color-primary: #107c10;
  --color-secondary: #3a3a3a;
  --color-tertiary: #7fba00;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)