This article records the setup I currently use for Figma Variables in a design system, mainly so I can reference it later when extending components, themes, or tokens.
I split variables into several independent collections. This makes them easier to find in the right-side panel and keeps values for different purposes from mixing together.
| Collection | Type | Scope | Purpose | Main binding location |
|---|---|---|---|---|
| Primitives | COLOR | — | Raw color palette, e.g. ocean/50–900 | Not bound directly to components; referenced by Semantic |
| Semantic | COLOR | TEXTFILL / SHAPEFILL / FRAMEFILL / STROKECOLOR etc. | Semantic colors, e.g. text/, icon/, bg/, border/ | Text, fill, background, stroke |
| Text | STRING | ALL_SCOPES | Text content tokens | Text characters |
| Radius | FLOAT | CORNER_RADIUS | Corner radius | Corner radius |
| Spacing | FLOAT | WIDTH_HEIGHT / GAP | Spacing, padding | Padding, item spacing, gap |
| Border | FLOAT | STROKE_FLOAT | Stroke weight | Stroke weight |
| Opacity | FLOAT | OPACITY | Layer opacity | Appearance → Opacity |
| Size | FLOAT | WIDTH_HEIGHT | Component height / width | minHeight / minWidth and similar size properties |
I split collections by purpose even when several of them share the same underlying type. Radius, Spacing, Border, and Opacity are all FLOAT, but keeping them in separate collections makes variable names and binding locations easier to reason about later.
Scope directly affects where a variable is visible in the right-side panel.
For example, even though CORNER_RADIUS, GAP, and STROKE_FLOAT are all FLOAT values, each one only appears in its matching field: corner radius, spacing, or stroke weight respectively.
A precise Scope keeps the variable panel much cleaner. Once a design system grows, having radius, spacing, size, and stroke all show up in the same variable picker makes it hard to find the right one quickly.
The plugin API's setBoundVariable can bypass Scope and force a binding anyway. I avoid doing this. The binding can succeed technically, but the Figma UI may not display or manage it correctly afterward, which makes later review and maintenance harder.
I treat Scope as the actual intended usage range, so designers only see relevant variables in each property field.
Both Spacing and Radius use a non-linear scale: denser at the small end, spreading out at the large end.
Spacing. Currently:
0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80Naming roughly follows size order: None → 2XSmall → XSmall → Small → Medium → Regular → Large → XLarge → 2XLarge, and so on.
Small spacing values are used far more often in the UI, so steps like 2, 4, 6, 8, 10, and 12 stay fine-grained. Beyond 24, the steps can spread out more.
Radius. Currently:
0, 1, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 99999999 is used as Full, suitable for pill buttons, circular tags, and other fully rounded shapes. Naming: None → XSmall → Small → Regular → Medium → Large → XLarge → Huge → Full.
Border. Only a few commonly used values are kept:
0 = None
1 = Thin
1.5 = Regular
2 = ThickIf the product has no real use case for 3px or 4px, I don't build out a complete scale just to have one.
Opacity. Uses plain percentage integers:
5, 8, 10, 20, 30, 40, 50, 60, 70, 80, 90Naming matches directly: 5% → 8% → 10% → 20%, and so on. Figma's Appearance panel already uses percentages for Opacity, so the variable value is written as 10 or 20, not 0.1 or 0.2.
Size. Built around real component dimensions. Currently mainly used for button height:
24 = XXS
28 = XS
32 = SM
36 = MD
40 = LG
48 = XLSize does not need to cover every possible value up front. New fixed sizes can be added when a component genuinely needs one.
Color is currently split into two layers:
Primitives → Semantic → ComponentPrimitives store raw color values, for example ocean/50 through ocean/900. This layer only represents the palette itself.
Semantic is named by usage, for example text/primary, text/secondary, icon/primary, bg/default, bg/brand, border/default. Semantic variables reference Primitives, and components bind to Semantic.
For example, a brand-color button binds to bg/brand in the component, and bg/brand currently points to ocean/600. If the brand color changes later, only the Semantic reference needs to change.
Light / Dark mode can use the same approach. Components keep using semantic variables such as text/primary and bg/default, without needing to change colors component by component.
So at the component layer, I try to avoid binding directly to a raw color value like ocean/600.
Both make something transparent, but they apply in different places.
| Layer Opacity | Paint Opacity / Color Alpha | |
|---|---|---|
| Location | Appearance section | Inside the Fill / Stroke color value |
| Scope of effect | The whole layer and all its children | Only the current Fill / Stroke |
| Common use | Disabled states, fade in/out, reducing a whole component's visual weight | Semi-transparent backgrounds, masks, overlays |
| Variable form | FLOAT in the Opacity collection | The Alpha channel of a COLOR variable |
I generally decide this way: for a temporary faded state such as Disabled affecting the whole component, use Layer Opacity; for a color that is inherently and permanently semi-transparent, such as an overlay or translucent background, put the alpha inside the color variable itself.
This distinction matters especially with nested components. Layer Opacity affects child elements too, so if the intent was only to make a background semi-transparent, text and icons inside it can end up unintentionally faded as well, stacking opacity where it wasn't wanted.
This structure already covers most of my variable usage in the design system. As new component types or themes are added, I extend the collections and values as the actual need comes up.