← All posts

From Figma variables to CSS tokens

From Figma variables to CSS tokens

We used to maintain design tokens by hand in CSS, aliased to Mantine (--type-body-size: var(--mantine-font-size-sm)). That worked until Figma and the product drifted. We wanted a wired-up Figma: take the variables we are using to design through to the production frontend app as generated CSS custom properties. Here’s how we did it, without Figma Enterprise (Code Connect) or a third party hosting service such as zeroheight.

The pipeline is three steps with deliberate manual steps to avoid accidental library changes: export from Figma, convert to CSS, import once.

1. A tiny Figma plugin exports local variables

Figma plugins can’t write to the repo or run pnpm. They can read figma.variables and download JSON. Ours does only that:

const collections = await figma.variables.getLocalVariableCollectionsAsync()
const variables = await figma.variables.getLocalVariablesAsync()

const payload = {
  collections: collections.map((collection) => ({
    id: collection.id,
    name: collection.name,
    modes: collection.modes,
    defaultModeId: collection.defaultModeId,
  })),
  variables: variables.map((variable) => ({
    id: variable.id,
    name: variable.name,
    type: variable.resolvedType,
    collectionId: variable.variableCollectionId,
    valuesByMode: variable.valuesByMode,
  })),
}

The UI iframe triggers a raw-tokens.json download. That file is committed under apps/figma-export/.

2. Name variables like the CSS you want

The converter is dumb on purpose: type/body/size becomes --type-body-size. The important work is in Figma, not the script.

Figma nameCSS
gray/9--gray-9
type/body/size--type-body-size
type/tone/default → alias of gray/9--type-tone-default: var(--gray-9)
xs in the size collection--size-xs (not --xs, which collides with Mantine)

Semantic tokens alias primitives in Figma. The script keeps that relationship instead of flattening to a raw rgb(...).

3. A small Node script writes CSS

pnpm build-tokens reads the JSON and writes apps/nextjs/src/styles/figma-tokens.css. A few rules made the output look like the old hand file:

  • Round Figma’s float noise (-0.4000000059604645-0.4)
  • Lengths as rem(14) so PostCSS can scale them
  • Weights stay unitless
  • "Inter" maps to the font we already load with next/font
if (raw && raw.type === 'VARIABLE_ALIAS') {
  const target = variablesById[raw.id]
  return `var(${toCssVarName(target.name, targetCollection.name)})`
}

// …
if (/(^|\/)weight$/.test(name)) return String(rounded)
return `rem(${rounded})`

What comes out:

:root {
  /* Type */
  --type-body-size: rem(14);
  --type-body-weight: 400;
  --type-body-family: var(--font-inter), var(--mantine-font-family);
  --type-tone-default: var(--gray-9);
  --type-tone-brand: var(--arva-blue-7);
}

The Next.js layout imports that file and nothing else for tokens:

import '../styles/figma-tokens.css'
import '../styles/globals.css'

What we didn’t do

The plugin does not run the converter. It has no filesystem and no shell. A Figma button that shells out to pnpm would need a local HTTP server and network access we don’t want.

We also didn’t alias everything to Mantine. Figma’s body is 14px (that matches --mantine-font-size-sm); its label is 14px too, while Mantine’s xs is 12px. Hardcoding Figma values as rem() is honest. Pointing at Mantine when the numbers differ is not.

Tokens the app still uses that aren’t in Figma yet (sidebar motion, section titles, danger/success tones) are generated as fallbacks. When those exist in Figma, the next export drops the fallback.

An admin page at /admin/design-tokens reads the live custom properties and lays out colour scales, type specimens, and sizes — useful while the names are still moving.

design-tokens.css is updated! Figma is the source of truth; CSS is an artefact.

Next step: implement Storybook…

More to read

See all

Comments

Loading comments…

Not published. Required for moderation.