The Friday afternoon specificity nightmare
You are debugging a production issue at 4 PM on a Friday. A product card title is rendering in 14px Arial instead of the new 24px geometric sans serif. You inspect the element in Chrome dev tools. The shiny new .text-heading-lg class is being completely crushed by an ancient .product-grid .card h2 selector from 2019.
The specificity score is 0-2-1 versus 0-1-0. The legacy CSS wins. You add an !important tag just to go home. You feel slightly sick doing it.
This happens constantly when you drop modern design tokens into older codebases. The design team gives you a beautiful set of typography variables. You export them. You write clean utility classes. But the browser cascade hates you.
So basically we need a way to protect our tokens from legacy selectors without relying on nuclear options. We need to fix the cascade.
Prerequisites for a clean token pipeline
You need a basic understanding of modern CSS features. We will use native CSS layers to flip the specificity rules. You also need a JSON file containing your typography tokens.
We want real bound aliases here. We do not want flattened pixels. If your Figma text style references a variable called font.size.lg, your JSON should explicitly say {font.size.lg}. Flattening these values destroys the entire point of a token system.
Step 1: Structuring the typography JSON
Let us look at how the W3C Design Tokens format handles text styles. A proper export maintains the references to your primitive variables. The $type property tells the parser what kind of data it is looking at. A typography token is a composite token. It holds an object of values instead of a single string.
{
"font": {
"family": {
"sans": { "$type": "fontFamily", "$value": "Inter, sans-serif" }
},
"size": {
"lg": { "$type": "dimension", "$value": "24px" }
},
"weight": {
"bold": { "$type": "fontWeight", "$value": "700" }
}
},
"typography": {
"heading": {
"lg": {
"$type": "typography",
"$value": {
"fontFamily": "{font.family.sans}",
"fontSize": "{font.size.lg}",
"fontWeight": "{font.weight.bold}",
"lineHeight": "1.2",
"letterSpacing": "-0.02em"
}
}
}
}
}
Notice how the heading style points directly to the base tokens. This is crucial. When the design team bumps the base size from 24px to 28px, the heading style updates automatically. You never want to maintain two separate sources of truth.
Step 2: Normalising Figma quirks
This is the one thing nobody talks about. You cannot just dump Figma values straight into CSS. You have to translate the design tool mental model into the browser mental model.
Figma often uses percentage values or raw pixels for line height. Standard CSS prefers unitless numbers. A good pipeline needs to normalise these units before generating the final stylesheet. Let us write a small JavaScript transform function to handle the line height normalisation. This is the exact type of code you would run in Style Dictionary.
function normaliseLineHeight(value) {
if (value.endsWith('%')) {
const percentage = parseFloat(value);
return (percentage / 100).toString();
}
if (value === 'AUTO') {
return 'normal';
}
return value;
}
console.log(normaliseLineHeight('120%')); // Outputs "1.2"
console.log(normaliseLineHeight('AUTO')); // Outputs "normal"
Step 3: Generating the CSS variables
We need to turn that clean JSON into CSS variables. We will map the typography object into a set of custom properties scoped to the root element.
:root {
--font-family-sans: Inter, sans-serif;
--font-size-lg: 24px;
--font-weight-bold: 700;
--typography-heading-lg-font-family: var(--font-family-sans);
--typography-heading-lg-font-size: var(--font-size-lg);
--typography-heading-lg-font-weight: var(--font-weight-bold);
--typography-heading-lg-line-height: 1.2;
--typography-heading-lg-letter-spacing: -0.02em;
}
We now have our raw materials. But creating a .text-heading-lg class right now leaves us vulnerable to the legacy CSS monster. We need to wrap it in armor.
Step 4: Defining the CSS Layer architecture
This is the magic part. CSS @layer rules let us explicitly tell the browser which styles should win. It completely overrides standard selector specificity.
We define the layer order at the very top of our main stylesheet.
/* The order defines priority. Layers defined later win. */
@layer legacy, design-system, overrides;
The browser reads this and makes a firm rule. Any style inside the design-system layer will beat any style inside the legacy layer. It literally does not matter if the legacy selector is #app .sidebar div.card h2. A simple .text-heading-lg class inside the design system layer will win every single time.
Step 5: Wrapping your legacy code
You take your massive old stylesheet and wrap it in the legacy layer. You do not have to refactor the old code yet. You just put it in the box.
@layer legacy {
.product-grid .card h2 {
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: normal;
}
}
Step 6: Writing the token classes
Now we map our CSS variables to utility classes inside the design system layer.
@layer design-system {
.text-heading-lg {
font-family: var(--typography-heading-lg-font-family);
font-size: var(--typography-heading-lg-font-size);
font-weight: var(--typography-heading-lg-font-weight);
line-height: var(--typography-heading-lg-line-height);
letter-spacing: var(--typography-heading-lg-letter-spacing);
}
}
The Output in action
Let us look at the HTML structure.
<div class="product-grid">
<div class="card">
<h2 class="text-heading-lg">Super Fast Coffee Machine</h2>
</div>
</div>
The browser sees two conflicting rules for the h2 element. The legacy selector has a specificity of 0-2-1. The new utility class has a specificity of 0-1-0. Normally the legacy rule would win.
But because of our layer definition the browser checks the layer order first. The design-system layer comes after the legacy layer. The new typography tokens win immediately. No !important flags required. The code stays clean. You actually get to fix legacy UI piece by piece without breaking the whole layout at once.
This layer architecture gets even more powerful when you introduce multi-brand themes. You can define your theme variables inside the same design system layer. The cascade protects your brand overrides from legacy code just as effectively.
@layer design-system {
.theme-brand-a {
--font-family-sans: Inter, sans-serif;
--font-size-lg: 24px;
}
.theme-brand-b {
--font-family-sans: Roboto, sans-serif;
--font-size-lg: 26px;
}
}
Honestly I got tired of writing custom parsers for this exact problem. Flattened text styles were driving me crazy on large refactoring projects. So basically I built a plugin called Design System Sync to solve it. We just shipped Text Styles Export v2. The Text Styles tab now exports as first-class W3C composite tokens. Bound variables are preserved as proper aliases like {font.size.lg} instead of being flattened to raw 24px. It also normalises letter spacing and line height to standard CSS units automatically. We also added a Studio tier for engineers working across multiple machines. If you want to stop fighting Figma API quirks you can check out the plugin at https://ds-sync.netlify.app?utm_source=devto&utm_medium=post&utm_campaign=bot or on the Figma Community at https://www.figma.com/community/plugin/1561389071519901700?utm_source=devto&utm_medium=post&utm_campaign=bot.
Turns out there is a lot to cover when you start treating design systems as software. I actually just published a companion handbook called "Ship Your Design System" on Amazon. It is a 200-page guide covering everything from token architecture to RFC workflows. Chapter 12 has a full case study on sync pipelines. It is basically the manual that should have come with the tools we use every day. You can find it at https://alexanderburgos.netlify.app/books/ship-your-design-system.
Top comments (0)