Styles and variables solve the same surface problem — reuse a value in more than one place — and that surface similarity is exactly what causes teams to reach for a variable and stop thinking about it the way they would a style. A style is a name attached to one fixed value; apply it in ten places and all ten places hold that same literal value until someone edits the style. A variable is a name attached to a reference, and the value that reference resolves to can depend on context — which mode is active, on which node, at the moment it's read. That single difference is what turns a color palette into a theming system, a spacing scale into something a density toggle can drive, and a well-organized set of variables into a genuine data layer the rest of the file reads from.
It's also exactly the property that makes variables harder to reason about than styles ever were. A style either applies or it doesn't; a variable can be bound correctly and still show the wrong value, because the value it resolves to depends on a mode assignment sitting somewhere else in the node tree — sometimes several collections away. Understanding variables well enough to debug that means understanding modes, scoping, and aliasing as a system, not as three independent settings in an editing panel.
01Variables vs. styles
Figma still supports text, effect, and grid styles, and for a single fixed value with no theming requirement, a style remains the simpler tool — there's no mode machinery to reason about. Variables become the better fit the moment a value needs to be the same reference resolving differently rather than a single fixed value: a background that has to be white in one theme and near-black in another, a corner radius that has to shrink uniformly in a "Compact" density mode, a button label that has to hold different placeholder text per locale for layout testing.
One boundary catches almost everyone at least once: color variables store solid values only — hex, RGB, or an alias to another color variable — and bind to fills, strokes, and backgrounds. They cannot store a gradient. A gradient fill still has to be a style, full stop, regardless of how central that gradient is to the brand's theming. Teams building a "everything is a variable" token pipeline run into this fast, and the practical fix is boring but effective: keep gradients on styles, keep every solid color on variables, and don't treat the two systems as interchangeable just because both live in the same Assets panel.
02The four variable types and what they bind to
Every variable is created as exactly one of four types, fixed at creation and never mixed within a single collection. A collection holding Color variables can't also hold a Number variable — a second collection is the only way to combine types side by side in the same organizational group.
| Type | Controls | Typical bindings |
|---|---|---|
| Color | A solid color value (no gradients) | Fills, strokes, backgrounds |
| Number | Any numeric value | Corner radius, padding, gap, width/height, opacity, stroke weight, effect values, and several text properties |
| String | Editable text | Text content, font family, font style |
| Boolean | True/false only | Layer visibility — nothing else |
The Boolean row reads short because it is: exactly like a Boolean component property, a Boolean variable can only ever show or hide the layer it's bound to. Reaching for a Boolean variable to "toggle" a color or a size runs into the same wall Boolean component properties do, for the same reason — that isn't binary data, it's a value, and it belongs on a Number or Color variable instead, usually switched through a mode rather than a true/false flag. Number variables carry the most surface area of the four: beyond the obvious spacing and sizing fields, a Number variable can also render directly as a text layer's content, which is what makes a variable-driven line-item counter or a live numeric label possible without a plugin.
03Collections and modes: the parallel-value-set model
A collection is a named group of variables that all share the exact same set of modes — Light and Dark, Comfortable and Compact, Brand A and Brand B, or just a single default mode if theming isn't needed yet. Every variable in that collection gets one value per mode, laid out as columns in the variables panel; the left-most column is always the collection's default mode, and any object using a variable from that collection falls back to the default mode's value until something explicitly says otherwise.
That "something" is an explicit mode set on a frame, section, or page — pick Dark from a dropdown on a top-level frame, and every descendant that reads from that collection inherits Dark unless one of those descendants sets its own explicit mode instead. This inheritance is what makes a single toggle capable of re-theming an entire screen: nothing on the canvas has to be touched individually, because none of those layers holds a literal value to begin with — they hold a reference that re-resolves the moment the ancestor's mode changes.
One axis, one collection. It's tempting to put every themeable dimension into a single collection's modes — "Light", "Dark", "Light-Compact", "Dark-Compact" — but that approach cross-multiplies by hand instead of letting Figma do it per node. A Color-scheme collection with two modes and a separate Density collection with two modes let any frame combine them independently; a single four-mode collection forces every future combination to be authored explicitly, and the count only gets worse as axes are added. Section 06 works through exactly how fast that grows.
04Scoping narrows the picker, not the value
Scoping is the part of the variables system most often confused with validation. It isn't one. A scoped variable still holds exactly the same value it would unscoped — scoping only controls which property fields offer that variable as a pickable option in Figma's UI. A Number variable scoped to Gap doesn't stop being a number, and nothing stops a plugin or the REST API from binding it to corner radius anyway; scoping is a UI-level guardrail against accidental misuse inside the design tool, not a type constraint enforced everywhere the variable is used.
Scoping is available on Number, Color, and String variables. Boolean variables aren't scoped at all, because visibility is already the only field a Boolean can bind to — there's nothing left to narrow. The available scopes differ by type:
| Type | Available scopes |
|---|---|
| Color | All fills, Frame fill, Shape fill, Text fill, Stroke color, Effect color |
| Number | Corner radius, Width & height, Gap, Opacity, Stroke weight, Effect value, Text content, Font weight, Font size, Line height, Letter spacing, Paragraph spacing, Paragraph indent |
| String | Text content, Font family, Font style |
All Scopes is a special case rather than just another checkbox: setting it makes the variable available everywhere its type is supported and disables every other scope option, since a variable can't be both "shown everywhere" and "shown only in these specific fields" at once. In practice, the tighter scope wins the maintenance argument almost every time — a spacing variable scoped to Gap and nothing else can't accidentally end up bound to a corner radius by someone skimming a long, unscoped variable list at 2am before a release.
05The aliasing chain: primitive → semantic → component
A variable's value, for a given mode, doesn't have to be a literal
number or hex code — it can instead be a reference to another
variable, called an alias. Aliasing is what turns
a flat list of variables into a layered system, and the
convention most design-systems teams converge on independently is
a three-tier chain: primitive variables hold raw,
context-free values (a palette step, a spacing unit on a base-4
scale); semantic variables carry intent-based
names — color/bg/surface, color/text/primary
— and alias into the primitives, with the theming modes (Light,
Dark, Brand) living at this tier; component-level
variables alias into the semantics again, scoped tightly to one
property on one component, one step removed from anything a
designer typically needs to touch directly.
The mechanism behind that chain has one detail that trips up
almost every team the first time a theme doesn't switch the way
it's supposed to: an alias points at a variable, not at
one of its modes. Setting color/bg/surface's
Light-mode cell to alias gray/100 doesn't lock that
link to whichever mode of the Primitives collection "means" light
— if the Primitives collection itself carries more than one mode,
the resolved value still depends on whichever mode of
that collection the consuming node has selected,
independently of the semantic tier's own mode. Figma's own
engineers have confirmed there's no way to pin an alias to one
specific mode of the target variable in the editor UI at all —
aliases simply don't carry that information.
The practical consequence is resolution that has to be walked collection by collection, not read off in one step. With N collections chained together by aliases and M modes in each, the same named variable can resolve to as many as M N different literal values depending purely on which mode each collection has active on the node actually consuming it — explicit where one was set, inherited from an ancestor otherwise. Most teams sidestep the ambiguity at the source rather than debug it after the fact: keep the Primitives collection to a single mode wherever possible, so theming only ever branches at the semantic tier, and the alias chain has exactly one place where "which mode?" is a live question instead of two or three.
06Where theming breaks under real combinations
A token system built and tested with one theme active can look completely solid and still come apart once real combinations of modes, collections, and nested overrides start interacting. Three patterns account for most of it.
Modes crammed into one collection instead of split across several. A single collection with modes named "Light", "Dark", "Light-Compact", "Dark-Compact" has to be authored by hand for every combination that exists today, and every new axis doubles that list again:
| Structure | Axes | Combinations to author |
|---|---|---|
| One collection, cross-produced modes | Scheme × Density × Brand | 2 × 2 × 2 = 8 modes, defined explicitly |
| Three collections, one axis each | Scheme + Density + Brand | 2 + 2 + 2 = 6 modes total; any of the 8 combinations resolves automatically per node |
The second row isn't a modeling trick — it's the actual advantage of keeping independent axes in independent collections. A node's resolved value already depends on the mode selected in every collection in its alias chain, so three collections with two modes each already cover all eight combinations without anyone defining "Dark-Compact-BrandB" as its own named thing anywhere.
An explicit mode set low in the tree. Because mode inheritance flows down from ancestor to descendant, setting an explicit mode on a small nested frame — often left over from testing a component in isolation — silently opts that one subtree out of whatever a page-level toggle does afterward. A card that "won't switch to dark mode" is very often not a binding problem at all; it's an explicit override sitting a few frames up that a page-level toggle can no longer reach.
Aliases that reach into a separate published library. Cross-file aliasing works the same way structurally, but tooling built around it has a real, documented gap: the Variables REST API currently can't be used to create a new alias pointing at a variable that lives in a different, already-published library — that specific edit still has to happen by hand inside the file that owns the alias. Automation that assumes every edit is scriptable end-to-end tends to discover this gap the first time it tries to wire two libraries together programmatically rather than by hand.
07Syncing variables with code
Every variable can carry a separate code syntax —
up to one name each for Web, iOS, and Android — layered on top of
its Figma-facing name. That separation matters more than it looks:
a variable named color/action/primary in the
variables panel can surface in Dev Mode's inspect panel as
--color-action-primary for a web engineer without
Figma's own hierarchical naming convention having to match
whatever convention the codebase already uses. Renaming one side
doesn't rename the other automatically — someone has to keep both
in sync deliberately, but at least the two naming systems are
allowed to diverge on purpose instead of by accident.
For teams that need more than a one-way glance in Dev Mode, the Variables REST API exposes endpoints to query, create, update, and delete variables and collections directly — enough to build a real two-way pipeline between a Figma file and a codebase's token source. It's gated more tightly than most of Figma's REST surface, though: both the GET and POST endpoints require an Enterprise plan and a Full seat, which rules out Guests and lower-tier plans regardless of how the rest of the workflow is built.
A typical pipeline treats Figma as the source of truth, pulls the
current variable set, remaps Figma's naming to the codebase's own
convention — color/green/100 in Figma might need to
become theme.colors.green-100 in a stylesheet — and
emits output through a tool like Style Dictionary, often
validated against the draft W3C Design Tokens Community Group
format so the same exported JSON stays usable outside any one
vendor's tooling. Two details catch teams building this for the
first time:
- The raw API response represents an alias as a pointer to another variable's ID, not as a resolved literal — a sync script has to walk the same alias chain covered in section 05 itself if the destination format expects a flat value rather than a reference.
- Syncing in the other direction — pushing token changes from code back into Figma — works for literal values, but, as in section 06, can't create new cross-library aliases through the API; anything that needs to reference a separate published library still needs a manual step inside the file.
08A worked example
A small token architecture makes the three tiers concrete. Every line reflects a deliberate choice about which tier a value belongs on, not a default reached for out of habit:
Primitives (collection · 1 mode: Value)
├─ color/gray/50 … color/gray/950
├─ color/blue/50 … color/blue/900
└─ space/1, space/2, space/3 … space/16
Semantic/Color (collection · modes: Light | Dark)
├─ color/bg/page → alias color/gray/50 | color/gray/950
├─ color/bg/surface → alias color/gray/100 | color/gray/900
├─ color/text/primary → alias color/gray/900 | color/gray/50
└─ color/action/primary → alias color/blue/600 | color/blue/400
Button (component-level, scoped)
├─ Fill → alias color/action/primary (scope: Frame fill)
├─ Label → alias color/text/on-action (scope: Text fill)
└─ Gap → alias space/2 (scope: Gap)
Nothing about the Button component's own definition changes between themes — its Fill property holds an alias, not a color, and that alias resolves through Semantic/Color, which is the only collection in this chain that carries more than one mode. Flip the page's mode from Light to Dark and every Button instance's fill and label color update together, because the thing that changed is which mode the Semantic collection is resolving against, not any property on any individual instance.
09Cheat sheet
- Scope narrows where a variable shows up in property pickers — it never restricts what value the variable is actually allowed to hold.
- Gradients still require styles; a solid-color variable can't store one, no matter how central that gradient is to the brand.
- Boolean variables aren't scoped, because layer visibility is already the only field they can bind to.
- An alias points at a variable, not at one of its modes — the resolved value always depends on whichever mode the consuming node has selected, explicitly or inherited, for every collection in the chain.
- Keep a Primitives collection to a single mode wherever possible; that's what stops the alias chain from branching in two places at once.
- Model independent axes — color scheme, density, brand — as separate collections rather than as one collection's cross-produced modes. Each stays independently toggleable, and a new axis never touches the others.
- A "won't switch to dark mode" bug is very often an explicit mode set on a nested frame, not a binding problem — check for a stray override before assuming the alias chain is broken.
- Code syntax is a separate, per-platform name layered on top of a variable; renaming the variable in Figma doesn't rename the token in code, or the other way around, unless someone updates both deliberately.
- The Variables REST API is gated to Enterprise plans with a Full seat, and can't create new aliases against a variable in a separate published library — plan a sync pipeline around both limits before building on top of it.