Design & ProductARTICLE

Design Tokens in Figma with Variables: from primitive to front-end delivery

How I structured primitive, semantic, and component tokens with theme and platform modes, and exported them in a way the front end consumes without retyping values.

Design Tokens in Figma with Variables: from primitive to front-end delivery
Image: Yara Uchôa

Design Tokens in Figma with Variables: from primitive to front-end delivery

How I structured primitive, semantic, and component tokens with theme and platform modes, and exported them in a way the front end consumes without retyping values.

The problem isn't creating a pretty color in Figma. It's that the front-end team gets a loose #0A84FF in the inspect panel, decides the variable name by hand, and three sprints later nobody knows whether blue-primary in the CSS is the same as Primary on the checkout screen. Design tokens exist to kill that lost-in-translation gap. I'll show the structure I built from scratch, with multiple modes (theme and platform) and the export that the front end actually consumes.

I used Figma's web version (Dec/2025). One plan-related detail matters before you start: according to the official Variables guide, publishing variables to team libraries is available on the education plan and on any paid plan. Editing and creating variables, however, works for anyone with edit access to the file; and anyone with access to the file can use its variables in their own designs. For the export part, I used a personal REST API token and Node 20.

The three token layers (and why separate them)

The rule I follow: never apply a raw value to a component. The hierarchy has three levels, each in a separate Variables collection.

1. Primitives (the raw palette). These are the literal values, with no meaning of use. I create a Primitives collection with a single mode. This is where color/blue/500 = #0A84FF, color/gray/900 = #1C1C1E, space/4 = 8, and radius/md = 12 live. Nobody consumes a primitive directly on screen. It's raw material.

2. Semantic (the intent). The Semantic collection, and this is where modes come in. A variable like bg/surface doesn't hold a hex value: it references a primitive. In Light mode, bg/surface points to color/gray/50; in Dark mode, to color/gray/900. I do this with aliasing: instead of typing a color when defining the variable's value, I click the variable icon and pick the primitive. It's this chaining that makes dark mode work by switching a single mode.

3. Component (local coupling). The Component collection, for tokens that only one component uses, like button/primary/bg. It references the semantic token (action/primary), not the primitive. Not every project needs this layer; on a small design system I stop at semantic. But on a large team it isolates changes: tweaking the button doesn't leak into the rest.

The chain looks like this: button/primary/bg → action/primary → color/blue/500 → #0A84FF. Rebranding the entire product is just editing one primitive.

Modes for theme AND platform at the same time

Here's the trick many people get wrong: don't stack theme and platform in the same collection. If you create modes like Light-Web, Dark-Web, Light-Mobile, Dark-Mobile in a single collection, the combination explodes and becomes unintelligible.

I split it into two dimensions:

  • The Semantic collection with Light and Dark modes (theme dimension).
  • The Scale collection with Web and Mobile modes (platform dimension), holding spacing and font sizes. E.g.: space/gutter = 16 on Web and 24 on Mobile.

The feature underpinning this is per-variable modes, described in the documentation as creating multiple definitions for the same variable, each tied to a mode, to quickly switch the design's context. Each frame can apply one mode per collection. I select the screen, and in the right panel choose Light for theme and Mobile for scale. The two dimensions combine without multiplying variables. I confirmed it worked by switching only the theme mode on the login screen: background, text, and borders turned dark at once, and the spacing didn't move, because it comes from a different collection.

The stumble: scoping and names that break on export

Two problems cost me time.

First, scoping. By default, a color variable shows up in any color field. That polluted the autocomplete: applying text color would surface primitives that shouldn't be used directly. The documentation treats scoping as part of creating and managing variables, defining which variables can be used in which designs. I fixed it by going into each primitive, opening the scoping tab, and disabling the unnecessary scopes, or by hiding the Primitives collection from the library publish. That way the designer only sees semantic tokens while working. A primitive is infrastructure; it shouldn't be in the day-to-day menu.

Second, slash-separated names become groups, but spaces become pain. Figma uses / to visually group things (color/blue/500 creates the color > blue folder). That's great. But I used spaces and capital letters in some names at first (Action Primary), and on export it turned into Action Primary as the JSON key, which broke the front-end parser. I redid everything in kebab-case and lowercase. Rule I settled on: a token name is a CSS variable name, so treat it as code from Figma onward.

Exporting for the front end without rewriting in CSS

The goal is for the front end to consume the tokens without retyping a single value. The documentation confirms Variables are supported in the REST API, with endpoints to query, create, update, and delete variables. I use the query to pull collections, modes, and values. The exact path, the response format, and how each value type (including cross-layer references) is represented all live in the REST API Developer docs, which the Variables page itself lists as a reference; that's where you check the contract before writing the script.

Editor's note: the Node code block shown below is illustrative, not functional. It only shows the name of a function (toCssVar) and comments describing the script's intent, but it doesn't actually implement the call to Figma's REST API (fetch, token, headers), the parsing of the variables and modes response, or the logic to resolve the chain of references across layers. From this snippet alone, the reader can't reproduce the output CSS shown below; that full logic needs to be written independently. Additionally, the text doesn't make clear that write operations (create, update, delete) on the Variables API require an Enterprise/Org plan with the appropriate scope; reading variables, which is what this tutorial uses, works with a regular personal token.

With the response in hand, I wrote a Node script that resolves the chain of references across layers and emits CSS custom properties scoped by mode:

js
// resolve reference -> final value and generate :root and [data-theme="dark"]
const toCssVar = (name) => `--${name.replaceAll('/', '-')}`;
// for each semantic mode, emit a block of custom properties
// Light -> :root  |  Dark -> [data-theme="dark"]

The output looks something like this, which the front end imports directly:

css
:root {
  --bg-surface: #FAFAFA;
  --action-primary: #0A84FF;
}
[data-theme="dark"] {
  --bg-surface: #1C1C1E;
  --action-primary: #0A84FF;
}

To avoid running the script by hand, Figma itself maintains a sample GitHub Action for syncing Variables with the codebase. The documentation describes a tutorial on using this Variables GitHub Action example repository to keep Figma and code in sync, plus a community write-up on building automated sync workflows using the Variables REST API. It's this kind of flow that turns the token from a sticker in the inspect panel into a single, versioned source of truth.

How I know it worked (definition of done)

A token is only done when three things happen.

First: switching to Dark mode on a screen flips the entire theme without editing any layer. Since semantics are aliases of primitives, the mode switch propagates on its own.

Second: Dev Mode shows the token name, not the hex value, when inspecting. The documentation notes that Variables show up in Dev Mode with variable details, suggestions, and the local collections table, which is exactly what the front end sees in the native inspect panel, without relying on you passing the value separately.

Third, and this is where accessibility enters as part of the definition of done: contrast has to pass in both themes. That's not something Figma's documentation covers, it's a UX criterion I don't outsource. With the semantics defined, I test every text/background pair in Light and Dark modes. Dark mode often fails on grays that looked fine in light. If text/secondary on bg/surface fails in Dark, the token is wrong, and since it's semantic, I fix it in one place, and every component that inherits from it gets fixed along with it.

Contrast that fails isn't a QA detail at the end: it's a poorly calibrated semantic token. That's why the accessibility check lives at the semantic layer, before any component inherits the value.

Translated from the Brazilian Portuguese original · Read the original

View profile →