DEV Community

Alexander
Alexander

Posted on

Mapping semantic colour roles to a Tailwind configuration

I was looking at a component file yesterday. A developer had written a massive string of utility classes on a primary button. It looked like a complete mess. It had specific blue hex classes for light mode. It had specific slate classes for dark mode. Then I checked another file. Someone else used entirely different shades for a supposedly identical button. The hardcoded utility classes were multiplying rapidly. We had no single source of truth for what a primary surface actually meant in our code.

When you write raw colour names in your styling framework, you are tying your code to a specific visual appearance. That is a dangerous game. If the design team decides the primary brand colour needs to be a bit darker, you have a massive problem. You have to run a global find and replace across your entire repository.

But you cannot just blindly replace everything. What if someone used that exact same blue for a chart line? Or maybe they used it for an informational banner. You have to check every single instance manually. Honestly, this is a massive waste of time.

The thing is, semantic colour roles fix this entirely. You stop naming things by what they look like. You start naming them by what they do. A button background is a primary surface. The text inside it is text on primary. A red error message is a danger surface.

Defining your naming convention

Before you write any code, you have to agree on a naming convention. This is usually the hardest part. Designers and developers need to speak the same language.

A good structure usually has three distinct levels.

First is the category. This covers base properties like colour, spacing, or typography.

Second is the concept or element type. This applies to things like surface, text, border, or icon.

Third is the specific role or state. This defines states like primary, danger, hover, or disabled.

When you combine these levels together, you get a highly predictable token name. A variable named color-surface-primary-hover tells you exactly what it is and where it belongs. Any developer can guess the name of a token without having to look it up in the documentation.

The prerequisites

You need a project with your utility CSS framework already installed. You also need access to your configuration file. I am using React for the component examples here. The logic applies exactly the same way to Vue or Svelte.

Building the semantic structure

Everything starts with a structured JSON file. This is your single source of truth. It holds the design decisions away from the actual application code.

{
  "color": {
    "surface": {
      "primary": {
        "default": { "value": "#2563eb" },
        "hover": { "value": "#1d4ed8" }
      },
      "danger": {
        "default": { "value": "#ef4444" }
      }
    },
    "text": {
      "on-primary": { "value": "#ffffff" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This structure is very intentional. We group things by their category first. Then we define the role. Finally we define the state.

Generating the CSS variables

You could manually type out all the CSS variables. That gets tedious very quickly. It is much better to write a small script to do the heavy lifting for you.

Create a simple Node script in your project root. Let us call it build-tokens.js.

const fs = require('fs');

const tokens = JSON.parse(fs.readFileSync('./tokens.json', 'utf8'));

let css = '@layer base {\n  :root {\n';

function flattenTokens(obj, prefix = '--color') {
  for (const key in obj) {
    const value = obj[key];
    if (value.value) {
      css += `    ${prefix}-${key}: ${value.value};\n`;
    } else {
      flattenTokens(value, `${prefix}-${key}`);
    }
  }
}

flattenTokens(tokens.color);
css += '  }\n}\n';

fs.writeFileSync('./src/tokens.css', css);
Enter fullscreen mode Exit fullscreen mode

This script reads the JSON file. It walks through the nested objects recursively. It flattens the structure into standard CSS variables. It writes the result to a new stylesheet.

The output looks exactly like what you would write by hand.

@layer base {
  :root {
    --color-surface-primary-default: #2563eb;
    --color-surface-primary-hover: #1d4ed8;
    --color-surface-danger-default: #ef4444;
    --color-text-on-primary: #ffffff;
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling the dark theme

You might be wondering why we do not just inject the hex codes directly into the framework configuration. The answer is dark mode.

If you put hex codes directly into your configuration object, you have to use prefix classes everywhere in your markup. Your components become bloated with conditional logic. You end up with a mess of utility classes.

When you use CSS variables, the browser handles the theme switch automatically. You just define a different set of variable values for your dark theme class.

@layer base {
  .dark {
    --color-surface-primary-default: #3b82f6;
    --color-surface-primary-hover: #60a5fa;
    --color-surface-danger-default: #f87171;
    --color-text-on-primary: #0f172a;
  }
}
Enter fullscreen mode Exit fullscreen mode

The component code never changes. The framework just references the variable. The browser figures out what colour that variable should be based on the active theme.

Updating the configuration

Now we need to tell our utility framework about these new semantic roles. We map the CSS variables to the theme object in the configuration file.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      colors: {
        surface: {
          primary: {
            DEFAULT: "var(--color-surface-primary-default)",
            hover: "var(--color-surface-primary-hover)"
          },
          danger: {
            DEFAULT: "var(--color-surface-danger-default)"
          }
        },
        text: {
          "on-primary": "var(--color-text-on-primary)"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how we use the DEFAULT keyword here. This is a nice little trick. It allows us to write shorter class names in our components. Instead of writing the full path every time, the framework knows to use the default value when no specific state is provided.

The final component

Let us look at how this changes the actual component code. This is where the magic happens.

export function PrimaryButton({ children }) {
  return (
    <button className="bg-surface-primary hover:bg-surface-primary-hover text-text-on-primary px-4 py-2 rounded-md transition-colors">
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Look at how clean that is. There are no hardcoded hex codes. There are no complicated dark mode prefixes. The class names describe exactly what the colours are doing. Any new developer joining the team can read this and understand the intent immediately.

If the design team decides to rebrand tomorrow, you do not have to touch this component at all. You just update the JSON file. You run the script to generate the new CSS variables. The entire application updates instantly.

This approach completely eliminates a whole category of visual bugs. People can no longer use the wrong shade of blue by mistake. They have to use the semantic role.

Tbh I got really tired of manually exporting these JSON files from my design tools. Copying and pasting hex codes across different files was driving me crazy. So basically I built a plugin called Design System Sync to automate this whole workflow. It exports all your variables directly to GitHub using automatic pull requests. You can check it out at https://ds-sync.netlify.app?utm_source=devto&utm_medium=post&utm_campaign=bot or find it on the Figma Community at https://www.figma.com/community/plugin/1561389071519901700?utm_source=devto&utm_medium=post&utm_campaign=bot if you want to try it out.

Top comments (0)