Figma variables still don’t understand systems
They’re great at holding values, but terrible at explaining how those values are derived.
Variables are one of the most important features Figma has shipped to date. They bring design closer to code, unlock new workflows, and finally give us something that feels like real infrastructure instead of clever workarounds.
But once you start using them in a real system, something feels off.
Variables are great at holding values. But they’re not so great at explaining how those values relate to each other.
That distinction matters more than it sounds.
In this article, I want to focus on one thing only: calculation and composition inside variables. I’m intentionally not talking about component state, bindings, or runtime interaction. This is about how values are authored and derived inside of Figma.
Where variables fall short
Most enterprise-level design systems don’t define everything independently.
They start with a small set of base values and then mutate them across states. Opacity is the most obvious example.
Instead of defining four colors for rest, hover, pressed, and disabled, teams define one color and adjust its opacity.
Material Design popularized this approach with state layers.
You’ve probably internalized this pattern already. It scales better, looks more consistent, and avoids duplication and drift.
The problem is that Figma variables don’t really support this mental model. They let you swap values, but they don’t let you derive them.
Composition starts with structure
Before we compose anything, we need a place to put shared structure.
Right now, variables are all primitives. Numbers, strings, colors. That works fine until you want to model something slightly more abstract, like interaction state.
So imagine a lightweight CMS inside Figma. Nothing fancy, just a place to define data that other variables can reference.
The first thing we define is state.
At this point, state is just a list of names:
rest
hover
pressed
disabled
No behavior yet, just a shared vocabulary.
Composition in practice (starting with color)
If you’ve worked in CSS or JavaScript, this idea should feel familiar.
In CSS, color and opacity are defined independently and composed through a function.
--color-bg-action-primary-default: rgb(
from var(--base-color-blue-600) R G B / var(--base-opacity-default)In JavaScript, it looks like this:
withOpacity(baseColorBlue600, baseOpacityDefault)The syntax doesn’t matter, but the pattern does.
You define base inputs, then define rules for how they combine. State doesn’t introduce new values, instead it modifies existing ones.
With the state object in place, we can start doing something similar in Figma. In the variables modal, we can now map over state.name.
Suddenly, Figma generates a predictable set of tokens:
primary-rest
primary-hover
primary-pressed
primary-disabled
This already feels better than hand-authoring permutations. The system is doing some of the work for us, but we can improve this further.
When composition breaks
The moment variables start referencing other variables, error handling becomes critical.
What happens if base/opacity/disabled doesn’t exist, but state still includes disabled?
If we allow this, Figma happily generates a broken variable. It looks real, but it doesn’t exist.
That’s dangerous.
One option is inline error states. Another is preventing invalid mappings altogether.
The cleaner solution is to link state directly to the opacity values themselves.
Now state names and values are inseparable. If a state exists, its opacity exists too, which means you can’t accidentally drift.
At this point, it starts to feel like type safety, just without forcing designers to think about types.
Shadows reveal the pattern
In Figma today, we can’t even define a composite shadow as a single variable. X offset, Y offset, blur, spread—and each of these has to be managed independently. There’s no way to describe a shadow as one cohesive object, and no built-in logic tying those values together.
So every “shadow token” ends up being a flat list of names in the styles panel:
shadow/low/restshadow/low/hovershadow/low/pressedshadow/low/disabled
Each one looks like a unique value, but they’re not. They’re all variations of the same underlying relationship.
If Figma supported composite shadow variables, a shadow could be defined as a structured object instead of 5 related values:
shadow.elevation.[state]xyblurspreadshadow-color
And instead of duplicating entire shadows for every state, those properties could be derived from a small set of inputs like depth and interaction state.
Nothing here is automated yet, but look closely. As depth increases, elevation increases. As interaction increases, intensity increases. The values start to feel related.
And if we converted this into a proper function, it would probably look something like:
Where:
y is the y-offset
b is the base value for a given depth
s is the interaction state
m(s) is a state-based multiplier
Blur is then always derived from the resolved elevation, not from depth directly:
And in this particular system, the multiplier looks like this:
None of this introduces new behavior. It simply makes existing behavior explicit.
After defining a few shadows this way, it starts to feel wrong. These aren’t really separate shadows. They’re the same logic repeated with different numbers.
And whenever I catch myself copy-pasting logic, I know I’m fighting the system.
Turning a pattern into a system
At this point, it’s pretty clear there’s a pattern here. So instead of continuing to imply it, we can just make it explicit.
We start by defining a shadow object.
Now shadow isn’t a style anymore. It’s just data:
low → 2
medium → 4
high → 8
There’s still no rendering here. We’re just describing structure.
Once we start introducing functions, state quickly hits its limit. State names are fine for labeling things, but they don’t help much when you’re trying to do math. So we extend the state object slightly.
Now state looks like this:
rest → 1
hover → 2
pressed → 0.5
disabled → 0
This isn’t about shadows specifically. It’s just a generic modifier. It answers a simple question: how much should this thing change in this state?
With that in place, the function basically writes itself. We multiply shadow.depth by state.scale to resolve elevation.
From there, both the Y-offset and blur are derived from that resolved value.
The exact math isn’t really the point. What matters is where the logic lives. Instead of being implied by copied numbers, the rules now exist directly in Figma, alongside the variables.
And once that’s set up, the payoff is obvious.
From just two small objects:
3 shadow depths
4 interaction states
We get 12 resolved shadows.
No duplication. No hand-authored permutations. Just a small system doing the repetitive work for you.
If you’re only dealing with a handful of shadows, this might feel like overkill. But the moment the requirements start stacking up, this approach holds up.
Layered shadows just become additional outputs. Direction becomes another input. The structure stays the same and the math simply resolves to different values.
The complexity never really disappears. It just moves out of an ever-growing token list and into a small set of rules.
Final thoughts
Variables were a huge step forward for Figma. The next step is letting them describe relationships, not just values.
This isn’t about making things more complicated. It’s about making complexity live in one place.
That’s the difference between managing values and designing systems.




















I love your concept, Josh! This truly feels like the next step.