DEV Community

Nikita Vakula
Nikita Vakula

Posted on

DRY for Design Assets

Quite some time ago, in a previous post, I explored expressing UI components in a tech-stack-agnostic way using Figma. The idea was appealing: tech stacks come and go, but the app should stay functional. Describe the geometry of your components once, in a neutral format, and let a code generator produce your React (or QML, or SwiftUI) components.

In practice, it didn't hold up. No mainstream generator supports this workflow, so you have to build one yourself - and the number of details to get right is enormous: margins, paddings, rotations, text metrics, and all their interactions. It is not impossible, but it is a serious maintenance burden. Writing components by hand is simply easier.

But rejecting full component generation doesn't mean rejecting the idea entirely. What if at least part of the design could flow from a neutral representation into code? Not the components themselves - the design decisions behind them: colors, spacing, typography, radii. It turns out there is a mature answer to exactly this question: design tokens.

TL;DR

  • Design tokens put every design value (colors, spacing, fonts, radii, shadows) into a JSON file in the W3C Design Tokens format - one source of truth that design tools and code generators both understand.
  • Style Dictionary generates platform assets from that file: CSS variables and JS modules for the web, and there are transforms for iOS, Android, and more.
  • Components never hard-code a value; they reference tokens. Dark mode becomes a data change, not a code change.
  • Implemented in the qcontroller UI in PR #21: tokens drive the Mantine theme, the CSS layer, and even an xterm terminal.

The Problem: Design Values Repeat Themselves

Every frontend accumulates the same sin: the brand blue lives in a CSS file, again in a theme object, again in some inline style, and once more in the design tool the mockups were drawn in. Change it in one place and you begin an archaeology project. This is a textbook DRY violation - we would never tolerate it for constants in backend code, yet for design values it is somehow the norm.

Design tokens fix this by making the values data. A token file describes what exists - color.blue.600, radius.pill, font.size.xs - and everything else derives from it:

{
  "color": {
    "$type": "color",
    "blue": {
      "600": { "$value": "#228be6", "$description": "Primary brand blue" }
    },
    "semantic": {
      "vm": {
        "running": {
          "indicator": { "$value": "{color.green.800}" },
          "text":      { "$value": "{color.green.900}" },
          "bg":        { "$value": "{color.green.50}" }
        }
      }
    }
  },
  "radius": {
    "$type": "dimension",
    "pill": { "$value": "999px", "$description": "status badges" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things are worth noticing. First, the {color.green.800} syntax: tokens can reference other tokens, which lets you build a semantic layer on top of raw palettes. Components never use "green" - they use vm.running.indicator, and the design decides what that means. Second, this format is a W3C community specification, supported by design tools such as Figma and Penpot. Designers keep working in their tool of choice; the tokens travel.

Style Dictionary: The Code Generator

So do we still need a code generator? Yes - but a much smaller and battle-tested one. Style Dictionary reads the token file and emits whatever your platforms need. The configuration is minimal:

import StyleDictionary from 'style-dictionary';

export default {
  source: ['design/tokens.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'src/generated/',
      files: [{ destination: 'tokens.css', format: 'css/variables' }],
    },
    js: {
      transformGroup: 'js',
      buildPath: 'src/generated/',
      files: [{ destination: 'tokens.js', format: 'javascript/esm' }],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

For the web that produces CSS custom properties - the token path becomes the variable name, references are resolved at build time:

:root {
  --color-blue-600: #228be6;
  --color-semantic-vm-running-indicator: #2f9e44;
  --radius-pill: 999px;
}
Enter fullscreen mode Exit fullscreen mode

plus a typed JS module for places where CSS variables can't reach. The same tokens can feed Android resources or iOS assets through other transform groups. In qcontroller's frontend, where code generation already produces the REST client and the WebSocket message types, tokens simply became a third generation target of yarn generate - generated files are never committed and never edited by hand.

Consuming Tokens: Theme Level, Not Component Level

Generating variables is only half the story; the discipline is in how they are consumed. The rule I settled on: components reference semantic tokens only, and no color literal exists outside the token files. In qcontroller's UI that meant two integration points.

The Mantine theme is built from the JS module, so every standard component picks the tokens up automatically:

export const theme = createTheme({
  primaryColor: 'blue',
  fontFamily: fontStack(tokens.font.family.sans),
  defaultRadius: 'sm',
  shadows: { xs: 'var(--shadow-card)', sm: 'var(--shadow-raised)' },
  // ...palettes, spacing, and headings, all derived from tokens
});
Enter fullscreen mode Exit fullscreen mode

And anything custom reads the CSS variables directly. The VM status badge, for example, renders its indicator dot, text, and background from the vm.* triple - it names roles, never values:

<Badge
  styles={{
    root: {
      backgroundColor: `var(--color-semantic-vm-${kind}-bg)`,
      color: `var(--color-semantic-vm-${kind}-text)`,
      borderRadius: 'var(--radius-pill)',
    },
  }}
>
Enter fullscreen mode Exit fullscreen mode

This even extends to components that don't paint via CSS: the log terminal (xterm) resolves the same variables at runtime with getComputedStyle and hands the concrete values to its renderer.

The Payoff: Dark Mode as a Data Change

The moment this architecture proved itself was dark mode. A second token file overrides only the semantic layer - same token names, different values - and Style Dictionary emits it scoped to a selector:

:root[data-mantine-color-scheme='dark'] {
  --color-semantic-surface-card: #1a1b1e;
  --color-semantic-vm-running-indicator: #51cf66;
}
Enter fullscreen mode Exit fullscreen mode

When the color scheme toggles, the attribute changes and every component repaints with the new values. No conditionals in components, no second stylesheet to maintain by hand, no re-render logic. The badge, the cards, the terminal - none of them know dark mode exists.

Conclusion

Full component generation from design files remains, in my experience, more trouble than it is worth. But the layer underneath the components - the actual design decisions - is perfectly suited for a single, tech-stack-neutral source of truth. Design tokens provide the format, Style Dictionary provides the generation, and even a small UI like qcontroller's benefits tremendously: consistent styling, an honest collaboration boundary between design and code, and features like dark mode that fall out of the architecture almost for free.

If your project has the same color defined in more than one place, that is the signal. Extract it into tokens, and let the generator do the repeating for you.

Top comments (0)