Primitive vs semantic tokens in Figma (and keeping them in sync)

One base color, purple-500, generating a full 100 to 900 tint and shade scale, with the base step marked.

How a two-layer token structure keeps one color change from turning into an afternoon of cleanup.

You make a change to one color and then you need to hunt down every shade, hover state, and dark-mode variant one by one. It really shouldn’t work this way, and it doesn’t have to.

The difference comes down to how you structure your tokens: two layers, primitives and semantics. It’s an approach most mature design systems already lean on. If you can wrap your head around this then a lot of the busywork disappears.

Primitives: your raw values

Primitives are the ground floor: raw, literal values with names. blue-500 is just #3B82F6 with a label on it. The name is something you’ll reference later, but a primitive just holds the actual value.

They come in families, not single swatches. Your blue runs blue-100 through blue-900, light to dark. Your spacing has space-1 through space-8. Your type scale has text-sm, text-base, text-lg. Each one is still just a literal: a hex code, a pixel value, a number.

A primitive never points at anything else. It sits at the bottom of the chain, the value everything above it will eventually reference.

If you write CSS, you already know this pattern. A primitive is a custom property holding a value:

:root {
- blue-500: #3B82F6;
}

Of course, this isn’t new, it just hasn’t always had a clean home in design tools.

Semantics: values with a job

A wall of blue-500, gray-200, space-4 tells you what colors and sizes exist, but nothing about where they belong. Here is where semantics come in.

Diagram showing a primitive token purple-500 referenced by a semantic token button-bg, which is used by a button component.”
Diagram showing a primitive token purple-500 referenced by a semantic token button-bg, which is used by a button component.”

A semantic token is named for its role, and instead of holding a value, it points at a primitive:

:root {
--button-bg: var(--blue-500);
--text-primary: var(--gray-900);
--surface-raised: var(--white);
}

button-bg doesn’t know that it’s blue. It knows it’s the background of buttons. Today that happens to be blue-500; tomorrow it could be blue-600, and every button would follow without you touching a single button.

That’s the whole point of the layer, to provide intent that is separated from value.

Why the split pays off

Everyone knows Verizon for their red. It’s one of the most recognizable brand colors there is. Now imagine that same red being used to flag errors and failures in a web interface. Who wants their brand color to mean “something went wrong”? Verizon doesn’t.

While working through iterations of one of their many forms, I’d inadvertently set the error states and negative alerts in that trademark red, and thought nothing of it at the time. Walking it back was the lesson. Going component by component, state by state, swapping that red out for a burnt sienna, ate up a huge chunk of my time.

That fix should have been one value. If those error states had pointed at a color-error token instead of the raw red, changing them would have been a single edit, not an afternoon of hunting. Another supporting case for the two layers.

It’s the same story with any base change. Say your brand blue shifts a little cooler, and blue-500 moves from #3B82F6 to #2563EB. With raw hex codes scattered through your file, you’re back to hunting: every button, every link, every focus ring, every hover state, one by one. With the two-layer setup, you change blue-500 in one place. Every semantic pointing at it, button-bg, link-color, focus-ring, updates on its own. And because your components reference the semantics, the components update too.

Change the meaning of “our blue” once, and it propagates throughout.

Where Figma variables hit a wall

Figma variables give you real tokens: named values, references between them, modes for light and dark. If you’ve set up variable collections, you’ve already built primitives and semantics, whether or not you called them that.

But variables hold flat values, and only flat values. There’s no way to say “this color is blue-500, lightened twenty percent.” So the moment you need a derived value, a hover that’s a shade darker, a full 50-through-900 ramp generated from one base, a tint for a disabled state, you’re back to producing those by hand and pasting in hex codes.

CSS is actually ahead of Figma here. Modern CSS can compute a derived color right in the reference:

--button-bg-hover: hsl(from var(--blue-500) h s calc(l - 10%));

Figma variables can’t do that. So your ramps and derived states get maintained the manual way, which is exactly the busywork we started with, just moved one layer down.

Structuring tokens so they hold up

However you generate the values, a few habits keep a token system from turning back into a mess:

Keep primitives and semantics in separate collections. One collection for the raw scales, another for the role-based tokens that reference them. Mixing them is how you lose track of which values are safe to change.

Let components reference semantics, never primitives. A button should use button-bg, not blue-500 directly. If components point at primitives, you’ve thrown away the whole indirection, and renaming or re-theming means editing components again.

Name semantics for the job, not the look. text-primary, not text-dark-gray. The name should survive a theme change. text-dark-gray becomes a lie the day you ship dark mode; text-primary stays true.

Name for where the tokens are going, not just where they live. This is the one I learned the hard way. Figma lets you group variables with slashes, color/brand/500, and it’s a tidy way to organize the panel. But those names don’t always survive the trip to code. I leaned on slashes early on, and when the tokens got exported to CSS custom properties, the structure I’d built in Figma didn’t map cleanly, and I spent time untangling names that should have just worked. A slash is a grouping convenience in Figma; in CSS it’s not a valid character, and color/brand/500 has to become something like --color-brand-500. If you name your tokens with the export in mind from the start, using separators that translate, you save yourself a cleanup pass later. Figma is only one end of the pipe.

None of this requires a plugin. It’s discipline, and discipline is free.

Keeping them in sync

The part that isn’t free is derivation: ramps, tints, hover and pressed states, anything computed from a base. That’s where “by hand” creeps back in, and it’s worth being honest about the options.

Manual discipline. You can maintain your ramps and derived states carefully, by hand, and plenty of teams do. It works right up until the base changes, and then you’re paying the tax again.

Tokens Studio. A powerful, established plugin with deep token features: math, multi-file sync, Git workflows. If you need the full pipeline and don’t mind the learning curve and the subscription, it’s the heavyweight in this space.

A yellow color ramp from 25 to 900, pale at the light end and shifting to olive-brown at the dark end.
A yellow color ramp from 25 to 900, pale at the light end and shifting to olive-brown at the dark end.

Token Foundry. I hit this wall enough times that I built a plugin for it. It keeps a token store right on your document, where colors can reference each other and carry modifiers, lighten, darken, mix, alpha, so you can generate a full ramp from a single base color. It resolves all of that down to literal values and writes them into Figma variables, or exports them as CSS. The whole idea is to close the gap I keep describing: change one base, and the derived values regenerate instead of getting retyped.

One thing building it taught me: generating good ramps by hand is harder than it looks, many systems can create ramps but not perfectly or consistently. Darken a yellow and it slides into murky olive. Lighten a saturated color toward a tint and it washes out into something barely on-hue. The math to keep a ramp looking intentional across all of that, in a perceptual color space like OKLCH, is real work, which is exactly why doing it by hand, one hex at a time, tends to fall apart the moment your base color changes.

Whatever you reach for, the goal is the same: stop storing values you could be deriving.

The one principle

If you take a single thing from this: derive, don’t duplicate.

Primitives are for the raw values. Semantics point at them and carry the intent and components point at semantics. The less you copy a value into a second place, the fewer places you have to chase when it changes, and the more your design system behaves like a system instead of a pile of hex codes that happen to match today.

Primitive vs semantic tokens in Figma (and keeping them in sync) was originally published in Bootcamp on Medium, where people are continuing the conversation by highlighting and responding to this story.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论