Matteo Maria Betto
Case study · Design system · Part 1: Taxonomy

One name for every token.

Our design tokens lived in three places: Figma, CSS and JSON. Each had its own naming rules and was maintained by hand, so they slowly drifted apart. I defined a single naming grammar. Now every token has one logical name, and the Figma path, the CSS property and the JSON key all follow from it mechanically.

semanticcolorprimarybackgroundhoversemantic.color.primary.background.hover
Role
Senior UX/UI Designer
Company
Bit2win · enterprise B2B SaaS
Timeline
September 2026 · migration ongoing
Scope
438 Figma variables · 3 sources of truth
Tools
Figma variables, CSS, JSON
01

Three sources of truth, three grammars

The same token existed in Figma, in the CSS files and in the JSON exported for code. Each source spelled it differently, and nothing kept them in sync except people.

CSSkebab-case--color-background-accent-primary-hover
Figmapath with “/”COLOR/Accent/Primary accent/Hover/background
JSONkeys with “.”COLOR.Accent.Primary accent.Hover.background

What the drift looked like

  • Wrong valueThe link colour pointed to brand-500 in CSS and to secondary-600 in Figma. Designers and developers were shipping different blues.
  • Typocolore-border-selected lived on as a real token name. Developers read these names and generate code from them.
  • MissingTwo surface tokens existed in Figma but had no counterpart in code.
  • HabitsState in the middle of the name, mixed upper and lower case, palette names inside semantic tokens, and components inventing their own tokens even when they didn’t need them.
02

The decision: a grammar with five axes

Instead of fixing names one by one, I proposed a grammar. Every token is described by the same five segments, always in the same order. Once the grammar exists, naming stops being a matter of taste and becomes something a reviewer can check.

tier.category.role.property.state
Tiersemanticcore · semantic · component
Categorycolorcolor · typography · spacing · border · elevation · motion
Roleprimaryprimary · secondary · neutral · destructive · alert
Propertybackgroundbackground · text · border · foreground
Statehoverdefault · hover · active · focus · disabled · selected

In code the tier is usually implied by the file a token lives in, so the semantic token above reads --color-primary-background-hover.

Fig. 1 · The classification
Tierbroadest Categoryfamily Rolemeaning Propertyapplies to Statealways last coresemanticcomponent colortypographyspacingborderelevationmotion primarysecondaryneutraldestructivealert backgroundtextborderforeground defaulthoveractivefocusdisabledselected semantic.color.primary.background.hover
Classified like a species. Each axis narrows the choice, from the broadest to the most specific. One path from top to bottom gives exactly one name, and the same path always gives the same name.
03

Seven problems, seven rules

Each rule in the grammar exists because of a problem we actually had. This is the part I’d point to in a review: not the names themselves, but the reason behind each one.

ProblemRuleWhat it changes
1

The state sat in the middle of the name

…accent-primary-hover in CSS, …/Hover/background in Figma.

Rule

The state is always the last segment, and appears once.

All states of one element sort together, and a developer can find the hover variant without guessing.

2

Mixed case everywhere

COLOR, Accent, SIZING & SPACING.

Rule

Every segment is lowercase. No exceptions.

Names translate to code without manual fixes. All 438 Figma variables have already been converted.

3

Palette names inside semantic tokens

A semantic token called brand tied a role back to a raw colour.

Rule

Semantic roles describe meaning: primary, not brand; destructive, not negative-500.

If the primary colour moves to another palette, no semantic name has to change.

4

Every component made its own tokens

The system grew without anyone deciding it should.

Rule

A component token exists only when the component diverges from the semantic value.

The system stays small. Each component token now signals a deliberate exception.

5

One variable, two jobs

The same variable was used for a fill and for a stroke.

Rule

One name per property: …background… and …border… are separate tokens, even if they alias the same value.

Fill and stroke can change independently later without a rename.

6

Three sources maintained by hand

Figma, CSS and JSON each needed their own update.

Rule

One logical name. The three formats are derived from it mechanically.

Figma’s code syntax is set to var(--token), so Dev Mode shows the exact CSS name.

7

Renaming could break every component

Deleting a variable and creating a new one detaches every instance that used it.

Rule

Rename, never recreate. The variable keeps its ID.

The migration runs on a live library without breaking a single component.

04

One name, three formats

The translation is a rule, not a judgement call. Anyone on the team can produce the CSS or JSON name from the Figma path, and a script could do it too.

Fig. 2 · Before and after
Before Three files, three grammars After One name, three outputs FigmaCOLOR/Accent/…/Hover CSS--color-…-primary-hover JSONCOLOR.Accent.….Hover ≠ ≠ ≠ Edited by hand, drifting apart. Defined once color.primary.background.hover derived Figmacolor/primary/… CSS--color-primary-… JSONcolor.primary.… Derived, always in sync.
The fix wasn’t correcting three files. It was to stop editing three files. The name is decided once; only the separators change between formats.
FormatRuleExample
Figma variableSegments separated by “/”color/interactive/primary/background/hover
CSS custom propertyKebab-case with a “--” prefix--color-interactive-primary-background-hover
JSON (Tokens Studio)Segments separated by “.”color.interactive.primary.background.hover

Tokens live in their own files (core-tokens.css, semantic-tokens.css, component-tokens.css), never inside the components. Component classes stay as they are. The class says what to style; the token says with which value.

05

Three tiers, one chain of references

Fig. 3 · What lives in core
Coreraw values · named by their place in a scale, never by use Colour · brandcore.color.brand.* 100 200 300 400 500 600 700 800 900 Colour · neutralcore.color.neutral.* 0 100 200 300 500 700 900 Spacingcore.spacing.* small · 4 default · 8 medium · 16 large · 24 xlarge · 32 Radiuscore.border.radius.* extrasmall · 2 small · 4 default · 8 large · 16 Border sizecore.border.size.* thin · 1 medium · 2 thick · 4 ✓ A core name says what the value is core.border.radius.small= 4px ✕ Never where it’s used core.button.radiususage belongs in semantic
Core is the raw material. Every value the product can use lives here exactly once, organised as scales. A core token doesn’t know about buttons or errors; it only knows it is the 600 of the brand blue, or the small radius. Meaning is added one level up.
Fig. 4 · From core to semantic
Corepalette · no meaning Semanticrole · points to a core value aliases brand.100 brand.500 brand.600 brand.700 neutral.0 neutral.100 neutral.900 red.600 primary.background.default primary.background.hover primary.background.active secondary.background.selected primary.background.disabled secondary.background.disabled neutral.background.default neutral.text.default destructive.background.default two roles,one value
The core palette has no opinions; the semantic layer does. Each semantic token points to exactly one core value, and several roles can share the same one, like both disabled backgrounds. To restyle a role you change where it points, never its name.
Fig. 5 · Three tiers in practice
Coreraw values Semanticroles Componentonly if it diverges In the UIwhat people see brand.500 brand.600 brand.100 primary.background.default primary.background.hover no token needed badge.background.selected softer than primary: it diverges Save Button, hover Selected Badge references aliases aliases
Read it right to left. The button in hover uses the semantic colour as it is, so it points straight to the semantic token. The selected badge needs a softer colour, so it earns a component token. Every component token is a deliberate exception.
No new token

Primary button, hover

It uses exactly the semantic colour, so it references the semantic token directly.

.button--primary:hover {
  background: var(--color-interactive-primary-background-hover);
}
Component token

Badge, selected

It needs a softer colour than the semantic default. It diverges, so it earns its own token.

.badge[data-state="selected"] {
  background: var(--badge-background-selected);
}
06

Before and after: the checkbox

The checkbox was the first component we migrated. Its six variables show almost every problem in one place.

Fig. 6 · Anatomy of the checkbox
Cornercore/border/radius/extrasmall Bordercolor/secondary/border/default Check iconcolor/secondary/foreground/default Fillcolor/secondary/background/default Border widthcore/border/size/medium Disabledcolor/secondary/background/disabled Surfacecolor/neutral/background/default
Every visible part, one token. Fill and border used to share a single variable; now each has its own name. Radius and border width are scale values, so they live in core.
#BeforeAfter (Figma path)Tier
1color/accent/secondary/default/default (fill)→color/secondary/background/defaultsemantic
1bsame variable, used as stroke→color/secondary/border/defaultsemantic
2color/accent/secondary/default/icon→color/secondary/foreground/defaultsemantic
3color/accent/disable state/disabled/disabled→color/secondary/background/disabledsemantic
4color/surface/default→color/neutral/background/defaultsemantic
5borders & radius/radius-extrasmall→core/border/radius/extrasmallcore (was semantic)
6sizing & spacing/border size/border-size-medium→core/border/size/mediumcore
Rows 1 and 1bOne variable used for two properties becomes two names.
Row 3“Disable state” isn’t a role, and “disabled” appeared twice.
Row 4The role was missing. It becomes neutral.
Row 5A scale value sat in the wrong tier and moves to core.
07

Where it stands

3 → 1
sources of truth for design and code
438
Figma variables converted to the new grammar
30+
components migrating, one audit at a time
  • DoneGrammar agreed with the team.
  • DoneCSS translation defined: separate token files and code syntax in Figma.
  • DoneAll 438 variables in the main library converted to lowercase.
  • DoneThe interactive colour category already follows the new grammar in JSON.
  • In progressComponent-by-component migration, driven by audits (checkbox, badge, tab, accordion…). Each audit lists which tokens still break the grammar.

Still open, on purpose

  1. Whether an 8px “panel” box pattern is its own level, or belongs case by case in the component tier.
  2. Whether surfaces with no padding need a fifth pattern level.
  3. Confirming the neutral role and the fill/stroke split for the checkbox.
08

What I took from it

A design system doesn’t break in its components. It breaks in its names, long before anyone notices.

Other work