Skip to main content
Juliano Alves
Back to blog

Design tokens with Tailwind and CSS variables

4 min read
By Juliano Alves

Design tokens are named decisions: “what is primary?” instead of “what is #7c3aed?”. When Tailwind is your utility layer, the cleanest pattern is CSS custom properties (variables) holding channel values (often HSL without hsl()), then mapping those variables inside tailwind.config.

This post walks through the full stack: tokens in CSS, Tailwind mapping, dark mode, and pitfalls with third-party components.

Why HSL channels#

If variables store full colors (#fff), you cannot derive translucent overlays with Tailwind’s opacity modifiers. Storing channels (220 14% 96%) lets you write:

background: hsl(var(--background) / 0.85);

…and in Tailwind v3:

colors: {
  background: 'hsl(var(--background) / <alpha-value>)',
},

So bg-background/80 works without extra token definitions.

Defining the palette in :root#

:root {
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --primary: 262 83% 58%;
  --primary-foreground: 210 20% 98%;
  --muted: 240 5% 96%;
  --muted-foreground: 240 4% 46%;
  --border: 240 6% 90%;
  --radius: 0.5rem;
}

.dark {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --primary: 263 70% 50%;
  --primary-foreground: 210 20% 98%;
  --muted: 240 4% 16%;
  --muted-foreground: 240 5% 64.9%;
  --border: 240 4% 16%;
}

Toggle .dark on <html> or <body> with class strategy, or use data-theme="dark" if you prefer attribute selectors.

Tailwind mapping#

// tailwind.config.ts (excerpt)
export default {
  theme: {
    extend: {
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      colors: {
        background: 'hsl(var(--background) / <alpha-value>)',
        foreground: 'hsl(var(--foreground) / <alpha-value>)',
        primary: {
          DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
          foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
          foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
        },
        border: 'hsl(var(--border) / <alpha-value>)',
      },
    },
  },
};

Components consume semantic classes: bg-background, text-foreground, border-border, bg-primary text-primary-foreground.

shadcn/ui alignment#

The shadcn stack is essentially this pattern packaged with Radix primitives. If you adopt shadcn, do not fight the token names unless you have a strong reason—third-party blocks assume muted, accent, destructive, etc.

Component-level overrides#

Sometimes a card needs a slightly different surface. Prefer a new semantic token (--card, --popover) over ad hoc bg-zinc-900 in one file. If you must override locally, document why in a short comment so the next refactor does not “clean it up” incorrectly.

Performance and SSR#

CSS variables are cheap. The cost is discipline: every new color should go through tokens, or the system rots into a hybrid mess.

Flash-of-unstyled-theme (wrong background on first paint) is solved by:

  • Inline critical background-color on <html> from the server based on cookie, or
  • Blocking script that runs before paint (tradeoffs with CSP—evaluate carefully).

Testing contrast#

Tokens make audits easier: run contrast checks on foreground vs background, primary-foreground vs primary, etc. Automate with axe in Storybook or Playwright.

Summary#

Tailwind + CSS variables gives you utility ergonomics with design-system semantics. HSL channels unlock opacity; a single .dark block unlocks theming; semantic names keep components decoupled from hex values.

© 2026 Juliano Alves. All rights reserved.