Figma Variables to CSS Custom Properties
A naming contract both teams can sign before the first export.
Engineering received a CSV export on Monday with rows labeled Primary/Blue/600, Neutral/Gray/500, and Semantic/Error. By Wednesday, the codebase had --blue-600, --primary, brandBlue, and a Tailwind extend entry called priBlue. Design insisted the names were already systematic. Engineering insisted they had followed the export. Both sides were telling the truth about their own tools, and both were wrong about the shared contract that should have existed before anyone drew a rectangle.
The fix is not a better export button in Figma Dev Mode. It is a signed map between Figma variable paths and CSS custom property names, maintained as a living document next to the token JSON both teams consume. Figma’s Variables overview describes collections, modes, and aliases on the design side. MDN custom properties describe the runtime surface on the engineering side. The handoff fails in the gap between those docs.
This article explains why exports lie politely, how to structure Figma collections, how modes map to CSS overrides, and a first-week failure that cost a rebrand sprint.
Why exports lie without a signed map
Figma variables are not CSS. They are a design-time graph with modes, aliases, and scoping that export tools flatten differently depending on which plugin, API, or Dev Mode copy you use.
Slash paths become inconsistent CSS. color/text/primary might export as --color-text-primary, --text-primary, or dotted JSON keys depending on the tool. Nobody is wrong until you pick one rule and enforce it. Modes flatten to separate variables instead of overrides when light and dark become --text-primary-light and --text-primary-dark as unrelated tokens. That doubles CSS and guarantees drift when only one mode updates.
Aliases break on rename when color/brand/accent aliases color/blue/600 and a designer renames the ramp without re-exporting JSON engineering still consumes. Dev Mode shows computed hex, not structure, encouraging hard-coded paste. Primitive and semantic collections merge in one CSV so engineering imports forty-seven grays nobody uses in components.
The export is a lossy serialization of intent. The map is how you preserve intent.
Schedule a short session with design systems, a front-end lead, and whoever owns Figma collections. Output one table. Both teams sign it. Changes go through pull request on the token repo.
| Figma variable path | CSS custom property | Notes |
|---|---|---|
color/text/primary |
--text-primary |
Body default |
color/text/secondary |
--text-secondary |
Captions, meta |
color/surface/default |
--surface |
Page background |
color/surface/raised |
--surface-raised |
Cards |
color/brand/accent |
--accent |
Primary CTA |
color/feedback/error |
--error |
Form validation |
Naming rules in the same file: Figma uses slash hierarchy, CSS uses hyphenation. Drop redundant color/ prefix in CSS unless Tailwind @theme needs --color-* namespace. Semantic roles in the engineering export, primitives only in a private Figma collection not exported to production CSS. No appearance names in public API like blue-600 in component stylesheets.
Collections, modes, and the CSS boundary
Split collections so exports are boring. Primitives are design-only: blue fifty through nine hundred, gray ramp, marketing one-offs. Not exported to root except through aliases. Semantic collection is the engineering contract: color/text/primary aliases a primitive or holds an explicit value. Component spacing collections follow the same signing process but sit outside this article’s scope.
Figma variable modes should align one-to-one with CSS override blocks. Light mode values live on :root. Dark mode values live on [data-theme="dark"] or inside prefers-color-scheme: dark when that is your documented choice. Pick class-based, media-query, or combined priority where explicit user choice wins. Designers must know which Figma mode maps to which CSS block. Document the priority order in the same wiki page as the map table so a new engineer does not reverse precedence during a late-night hotfix. A common working combination applies dark tokens from prefers-color-scheme unless data-theme="light" is set, while data-theme="dark" always wins. The exact logic matters less than writing it down where both teams can cite it.
:root {
--text-primary: oklch(0.22 0.02 260);
--surface: oklch(0.98 0.01 260);
--accent: oklch(0.55 0.18 264);
}
[data-theme="dark"] {
--text-primary: oklch(0.93 0.01 260);
--surface: oklch(0.18 0.02 260);
--accent: oklch(0.72 0.14 264);
}
If the site uses Tailwind v4, public variables often need --color-* namespace in @theme. Some teams map --color-accent: var(--accent) to keep non-Tailwind CSS on shorter names. Acceptable if automated, toxic if hand-maintained twice.
When week two invented brandBlue
A startup signed a map in week one, exported semantic variables manually, and wired one marketing page. Week two added a dashboard without re-reading the map. A new engineer copied hex from Dev Mode into components because it was faster than finding the token file. A designer added text/muted-2 in Figma for a one-off review without a map row. Tokens Studio export ran for the first time with a different slash-to-hyphen rule than the manual week-one export.
By week three the repository had --text-secondary, --text-muted, --gray-500 pointing at the same OKLCH coordinate, and brandBlue in a chart config. Dark mode was --surface-dark instead of overriding --surface because the CSV plugin appended mode suffixes. Contrast checks passed on the marketing page and failed on dashboard cards where muted text sat on raised surfaces.
Remediation reopened the map as enforceable infrastructure, not a wiki page. Nightly Variables API export opened token pull requests. Style Dictionary transformed JSON to theme.css with mode blocks, not suffixed names. CI failed if component directories contained raw hex. Storybook stories for Card, Button, and Input ran in light and dark with token-only colors. The spreadsheet contrast matrix became a required attachment on token changes.
Design renamed two primitive ramps during remediation. Because semantics were aliases, only alias targets changed in Figma and the map rows for primitives updated. Component CSS still referenced --text-secondary and --accent. Engineering did not grep components. The nightly export diff showed alias resolution changes in JSON, not forty-seven component file edits. That week paid for every hour spent signing the map in week one.
Dev Mode hex copy was restricted to a documented debugging workflow, not feature development. Engineers could paste hex into a scratch file to compare against computed styles, but pull requests with hex in component paths required an explicit exception comment linked to a ticket. The rule felt strict until week-three regressions dropped to near zero and design stopped receiving screenshots of colors that did not match variables because engineers had stopped bypassing the pipeline.
Figma governance required new public tokens to include light and dark values in the semantic collection, a map row, regenerated CSS, and a changelog entry for email and mobile consumers. Primitives in the private palette did not need engineering review. Semantic roles did. The lesson was that week-one alignment without week-two automation is a handshake, not a contract.
Export paths, OKLCH conversion, and validation
Figma REST or Variables API suits CI pipelines that pull JSON on schedule. Tokens Studio exports Design Tokens Format JSON with transforms to CSS, SCSS, and mobile platforms. Dev Mode copy is fine for exploration, never production. Manual CSV is acceptable for bootstrap week one, deprecated once API export works.
Pipeline shape: Figma semantic collection to nightly JSON to Style Dictionary or custom Node transform to theme.css with root and dark overrides to optional Sass layer for white-label to Tailwind @theme import. Engineering does not convert in meetings. A bot converts on schedule.
Figma stores sRGB hex internally. Perceptually uniform ramps are easier to maintain in OKLCH on the CSS side, especially for dark mode pairs that should feel equally bright. Workflow: designer sets semantic variables in Figma with hex that looks correct in sRGB previews, export delivers anchors, build script converts to OKLCH for accent and surfaces, designers validate in Storybook staging. Figma lacks native OKLCH authoring at scale. Document which side owns conversion, usually engineering at build with design sign-off on screenshots.
Hover states can export as separate semantic variables, compute in build from base accent, or use color-mix(in oklch, …) on site only. Pick one strategy per token family and write it on the map. Mixed strategies recreate primary versus primary-hover versus we mix in CSS only confusion.
Every text token on every surface token in every mode belongs in a contrast spreadsheet before merge. Run the matrix in WebAIM or your contrast tooling. Attach results to the ticket. Design signs contrast. Engineering signs naming. Visual regression on real components catches raised-surface failures spreadsheets miss.
Failure stories repeat: gray ramp exported while components use gray-500 instead of text-secondary; mode split with --surface-light and --surface-dark when high-contrast mode arrives unmapped; Tailwind collision with three hex sources for one accent; email fork still on last quarter’s CSV while web moves to OKLCH.
When the contract works, designers rename a ramp, aliases update semantics, nightly export opens a pull request, contrast bot comments, Storybook passes, production deploys --accent without a meeting about hex. Dev Mode copy is for debugging, not delivery.
Mobile and email consumers belong on the signed map even when this article focuses on CSS. iOS Swift, Android XML, and inline HTML email should receive colors from the same JSON export that generates :root custom properties. If email lags web by a quarter because nobody added transactional templates to the pipeline, users notice brand mismatch in receipts while the marketing site looks correct. One export, many translators, same semantic names across platforms.
Alias strategy for hover and pressed states should be decided before the first button ships. Figma can model color/brand/accent-hover as an alias to a darker primitive step. CSS can derive hover with relative OKLCH syntax at runtime. Email may require pre-computed hex from the same relative math at build time. Mixed strategies per component family recreate the confusion the map exists to prevent. Write the decision in the map notes column, not in Slack.
Enterprise Figma plans with Variables API access should treat token export as infrastructure with uptime expectations, not a designer’s manual export when remembered. Scheduled jobs that open pull requests create audit trails procurement and SOC reviews increasingly ask for. Who changed --accent last Tuesday becomes a git blame answer instead of a meeting. Designers retain authority over values in Figma. Engineering retains authority over naming, conversion, and validation in CSS.
Onboarding new hires should start with the map, not with Dev Mode. Junior engineers who learn hex copy first reproduce week-two brandBlue failures. Junior designers who invent text/muted-2 without a row break the contract before seniors notice. A thirty-minute map walkthrough costs less than one rework sprint.
High-contrast and accessibility modes in Figma should map to explicit CSS override blocks when product commits to support them. If Figma has a high-contrast mode but CSS never received a third override set, the accessibility audit will find tokens that exist in design tools and not in production. Either scope the mode out of the Figma library until engineering can support it, or add the CSS block in the same release as the Figma mode. Half-supported modes are worse than honest omission.
Quarterly map reviews catch drift before rebrands. Primitives renamed in Figma without alias updates, new modes added for seasonal campaigns, and deprecated roles still listed in engineering docs all show up in a thirty-minute review if the map is the canonical index. Treat the map like an API schema with owners, not like meeting notes from week one.
Security and licensing reviews sometimes ask where brand colors are defined. Pointing to a token repository with automated export from Figma is a cleaner answer than pointing to a shared Figma file and a manual CSV someone downloaded once. Auditability is an underrated benefit of the map-plus-pipeline model for enterprise design systems.
Rollback strategy belongs in the same document as the map. If a nightly export introduces a bad --accent value, how quickly can you revert the JSON commit and redeploy CSS? Keep token releases small and reversible. Large batch exports that change forty variables at once make rollback scary and encourage engineers to hard-code hex again during incidents. Small frequent token pull requests with contrast bot comments build trust in automation.
Designer-educator pairing accelerates map adoption. When a design systems lead and a front-end lead co-present the map to both teams, fewer exceptions appear in week two. Single-team presentations produce nodding heads and later exceptions. Joint presentations produce questions that update the map before the first export rather than after the first production bug.
Changelog entries for token releases should describe semantic impact, not only hex deltas. Users of your design system read release notes to learn whether text-secondary relationship to surfaces changed, not whether a coordinate moved zero point zero two in OKLCH L. Human-readable notes reduce support questions after token deploys.
Sandbox Figma files for experiments should not export to production CSS. Designers need freedom to try text/muted-2 without engineering importing it accidentally. Separate Figma team project permissions for semantic collections versus playground files, and restrict CI export sources to the semantic collection ID only.
The map is the product. Figma variables are one input. CSS custom properties are the output. Sign the contract early, automate the middle, validate contrast like you validate types, and stop inventing alternate names in week two.