Skip to content
C Codeloom
Tailwind

Tailwind Design Tokens and Theming

Design tokens give your Tailwind project a single source of truth. Learn how to model colors, spacing, and typography for multiple brands and themes.

·5 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • What design tokens are and why they matter
  • How to map tokens to Tailwind theme entries
  • How to support multiple themes from one codebase
  • How to use CSS variables for runtime switching
  • How to keep tokens consistent across designers and devs

Prerequisites

A design token is a name that points at a value. color.brand.primary is a token. #4f46e5 is a value. The whole point of a design system is that designers and developers agree on the names and let the values change. Tailwind is well suited to tokens because its config file is the natural place to define them and its utility classes are the natural way to consume them.

Why tokens beat raw values

Without tokens, a hex code is sprinkled across hundreds of files. Changing it means a risky find-and-replace, and you cannot support a second theme at all. With tokens:

  • Designers change a value once, developers pick it up by name
  • Multiple themes (light, dark, brand variants) share the same component code
  • Refactors are mechanical because names are stable
  • Accessibility audits run against tokens, not against scattered hex codes

A good token system has three layers:

  • Primitives — the raw palette (indigo-500, slate-900)
  • Semantic tokens — names tied to intent (color.surface, color.accent)
  • Component tokens — names tied to UI parts (button.primary.bg)

Components consume semantic and component tokens. Designers map them to primitives.

Defining tokens in tailwind.config

The cleanest pattern is to define semantic colors that resolve to CSS variables, then set those variables in CSS.

// tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  content: ['./src/**/*.{astro,tsx,html}'],
  theme: {
    extend: {
      colors: {
        surface: 'rgb(var(--surface) / <alpha-value>)',
        surfaceMuted: 'rgb(var(--surface-muted) / <alpha-value>)',
        fg: 'rgb(var(--fg) / <alpha-value>)',
        accent: 'rgb(var(--accent) / <alpha-value>)',
        accentFg: 'rgb(var(--accent-fg) / <alpha-value>)',
        border: 'rgb(var(--border) / <alpha-value>)',
      },
      borderRadius: {
        token: 'var(--radius)',
      },
      spacing: {
        gutter: 'var(--gutter)',
      },
    },
  },
} satisfies Config;

The <alpha-value> placeholder lets utilities like bg-accent/80 still work because Tailwind injects the opacity into the rgb() call.

Setting the variables

Each theme is a block of CSS variable definitions. Light is the base, dark is a .dark override, brand themes are class-scoped.

:root {
  --surface: 255 255 255;
  --surface-muted: 241 245 249;
  --fg: 15 23 42;
  --accent: 79 70 229;
  --accent-fg: 255 255 255;
  --border: 226 232 240;
  --radius: 0.5rem;
  --gutter: 1.5rem;
}

.dark {
  --surface: 15 23 42;
  --surface-muted: 30 41 59;
  --fg: 241 245 249;
  --accent: 129 140 248;
  --accent-fg: 15 23 42;
  --border: 51 65 85;
}

.theme-emerald {
  --accent: 16 185 129;
  --accent-fg: 255 255 255;
}

A component now writes bg-surface text-fg border-border and picks up whatever theme is active. To switch brands, add theme-emerald to a parent element. Themes compose: dark plus emerald is a real combination, not a hardcoded variant.

Typography tokens

Type scale and font families deserve the same treatment. Define a small named set rather than reaching for raw sizes.

fontSize: {
  caption: ['0.75rem', { lineHeight: '1rem' }],
  body: ['0.9375rem', { lineHeight: '1.5rem' }],
  lead: ['1.125rem', { lineHeight: '1.75rem' }],
  h3: ['1.5rem', { lineHeight: '2rem', fontWeight: '600' }],
  h2: ['1.875rem', { lineHeight: '2.25rem', fontWeight: '600' }],
  h1: ['2.25rem', { lineHeight: '2.5rem', fontWeight: '700' }],
},
fontFamily: {
  sans: ['var(--font-sans)', 'system-ui'],
  mono: ['var(--font-mono)', 'monospace'],
},

Components use text-h2 and font-sans. Swapping fonts means changing one variable.

Spacing and radii

Tailwind ships a sane spacing scale. Extend rather than replace. If your design language uses an 8-pixel grid, alias the values you use most.

spacing: {
  card: '1.25rem',
  section: '4rem',
},
borderRadius: {
  card: '0.75rem',
  pill: '9999px',
},

Now p-card and rounded-card exist as first-class utilities. A designer renaming “card padding” updates exactly one entry.

Component tokens

For tightly scoped tokens, put them inside the component as CSS variables.

function Button({ variant = 'primary', children }: ButtonProps) {
  return (
    <button
      data-variant={variant}
      className="
        rounded-token px-4 py-2 font-medium
        bg-[--btn-bg] text-[--btn-fg]
        hover:bg-[--btn-bg-hover]
      "
    >
      {children}
    </button>
  );
}

Then style by attribute:

[data-variant='primary'] {
  --btn-bg: rgb(var(--accent));
  --btn-fg: rgb(var(--accent-fg));
  --btn-bg-hover: rgb(var(--accent) / 0.9);
}
[data-variant='ghost'] {
  --btn-bg: transparent;
  --btn-fg: rgb(var(--fg));
  --btn-bg-hover: rgb(var(--surface-muted));
}

The component file does not know about hex codes. It knows about its own slots.

Syncing with design tools

Designers in Figma can publish tokens via a tool like Style Dictionary or Tokens Studio. The output is a JSON file. A small build script generates your Tailwind theme and the CSS variable blocks from it. Once the pipeline exists, designers update tokens in Figma, CI rebuilds, and developers pull the new values without manual edits.

A minimal version of the script:

import tokens from './tokens.json';

const css = Object.entries(tokens.color.light)
  .map(([k, v]) => `--${k}: ${rgb(v)};`)
  .join('\n');

Pitfalls

  • Putting every value behind a variable. Variables have a cost. Keep raw Tailwind utilities for one-off layout, use tokens for anything semantic.
  • Naming tokens after the color. --blue-bg becomes a lie when you ship a green theme. Name by intent.
  • Inventing tokens per component without curation. The token set should be small and reviewed.

Wrap up

Design tokens turn “what color is this button” into a single decision made once and consumed everywhere. Tailwind’s config gives you the surface for the names. CSS variables give you the runtime flexibility for multiple themes. Together they let one codebase ship light, dark, brand variants, and entire white-label products without touching component code.