Back to blog
Web DevJuly 25, 20257 min read

Building a Consistent Design System with Tailwind and CSS Variables

Utility classes are great until you have five pages with slightly different shades of the same button. Here is how we keep things consistent across multiple Execross properties.

When we were building SmuleVault and then StarSing and then Retik, we kept copying the same color palette and component styles between projects. At some point the copies diverged and we had three slightly different versions of what was supposed to be the same design language. Buttons were almost the same shade of blue. Shadows were close but not identical. It was death by a thousand tiny inconsistencies.

The fix was formalizing our design tokens as CSS custom properties and exposing them through Tailwind's config.

Setting up the tokens

In your global CSS file, define your palette as CSS variables:

css
:root {
  --color-primary: 139 92 246;
  --color-primary-glow: 167 139 250;
  --color-surface: 15 15 27;
  --color-border: 255 255 255;
  
  --shadow-glow: 0 0 20px rgb(139 92 246 / 0.3);
  --shadow-glow-lg: 0 0 40px rgb(139 92 246 / 0.4);
  
  --radius-card: 1rem;
  --radius-button: 0.5rem;
}

Notice the colors are defined as space-separated RGB channels rather than full hex values. This is intentional. Tailwind's opacity modifier syntax like bg-primary/50 requires the color to be in a format where opacity can be injected. If you use hex values, you lose the ability to write bg-primary/20 and have it work correctly.

Wiring into Tailwind

In tailwind.config.ts:

typescript
export default {
  theme: {
    extend: {
      colors: {
        primary: "rgb(var(--color-primary) / <alpha-value>)",
        "primary-glow": "rgb(var(--color-primary-glow) / <alpha-value>)",
        surface: "rgb(var(--color-surface) / <alpha-value>)",
      },
      boxShadow: {
        glow: "var(--shadow-glow)",
        "glow-lg": "var(--shadow-glow-lg)",
      },
      borderRadius: {
        card: "var(--radius-card)",
        button: "var(--radius-button)",
      },
    },
  },
};

Now you can write bg-primary, shadow-glow, rounded-card consistently across every component.

Dark/light theming

The real benefit shows up when you add theming. Instead of conditionally applying different classes, you just change the variable values:

css
[data-theme="light"] {
  --color-surface: 248 248 252;
  --color-border: 0 0 0;
}

[data-theme="dark"] {
  --color-surface: 15 15 27;
  --color-border: 255 255 255;
}

Flip the data-theme attribute on your root element and everything updates instantly. No class toggling on individual elements, no conditional rendering. The component code does not change at all.

Sharing across projects

We publish our design tokens as a small npm package that gets installed in each Execross property. The package exports the CSS variables string and the Tailwind config extension as an object. Each project imports them and merges into their own config.

This means when we update a shadow value or tweak the primary color, all properties get the update on their next dependency update. It has eliminated the drift problem almost entirely. The only exceptions are intentional overrides where a specific tool needs a slightly different visual treatment.

More articles