Skip to content

Building a design system with Tailwind CSS

Tailwind is a system without opinions about your system. Here is how to give it one — semantic theme keys, CSS-variable theming, and the discipline that keeps it.

By Srinidhi BhatUpdated 9 min read

Tailwind gives you a scale for everything and an opinion about nothing. Out of the box that is a spacing scale, a type scale and a palette of 22 colour families — a perfectly good default system, and not yours. Building a design system with Tailwind means replacing those defaults with your own, in a way that survives contact with a team.

The core move is small and everything else follows from it: stop shippingbg-indigo-600 and start shipping bg-action-primary. What that requires is a theme built from semantic tokens rather than from colour names.

Replace, don’t extend

theme.extend adds to Tailwind’s defaults; theme replaces them. For a design system you generally want both, applied deliberately:

ScaleReplace or extendWhy
colorsReplaceLeaving the defaults in means bg-sky-500 is always one keystroke away, and it will be used.
spacingReplaceYour scale is the contract. Two scales is no scale.
fontSizeReplaceType steps carry line-height and tracking with them; the defaults do not match your ratio.
borderRadiusReplaceA small named set — none, sm, md, lg, full — is what makes shape consistent.
screens, fontFamilyExtendThe defaults are sensible and rarely the thing a system needs to own.

Theme colours as CSS variables

Naming the theme keys semantically gets you half the way. The other half is making them themeable, which means the value behind each key has to be a CSS custom property rather than a hex.

tailwind.config.ts
export default {
  darkMode: "class",
  theme: {
    colors: {
      transparent: "transparent",
      current: "currentColor",

      // Semantic roles, not hues. Every value is a variable.
      surface:  "rgb(var(--surface) / <alpha-value>)",
      "surface-raised": "rgb(var(--surface-raised) / <alpha-value>)",
      fg:       "rgb(var(--fg) / <alpha-value>)",
      "fg-muted": "rgb(var(--fg-muted) / <alpha-value>)",
      line:     "rgb(var(--line) / <alpha-value>)",
      action:   "rgb(var(--action) / <alpha-value>)",
      "on-action": "rgb(var(--on-action) / <alpha-value>)",
    },
  },
};
globals.css
:root {
  --surface: 255 255 255;
  --fg: 24 24 27;
  --action: 79 70 229;
  --on-action: 255 255 255;
}

.dark {
  --surface: 9 9 11;
  --fg: 250 250 250;
  --action: 129 140 248;   /* a lighter step — see the colour guide */
  --on-action: 24 24 27;
}

Two details in there are doing real work. Channels without the rgb() wrapper 255 255 255, not rgb(255,255,255) — plus <alpha-value> is what keeps Tailwind’s opacity modifiers working, so bg-surface/60 still does what it looks like it does. And only the variables are restated in .dark: the theme itself has no idea a dark mode exists, which is exactly the property you want.

Tailwind v4

In v4 the same idea moves into CSS: @theme declares the tokens directly, and the config file becomes optional. The structure of the decision is unchanged — semantic names, values as variables, one place they are defined — but it is now expressed in the stylesheet rather than in JavaScript.

Tailwind v4 — app.css
@import "tailwindcss";

@theme {
  --color-surface: rgb(255 255 255);
  --color-fg: rgb(24 24 27);
  --color-action: rgb(79 70 229);
  --spacing-4: 1rem;
  --radius-md: 0.5rem;
}

Scales, not values

Spacing and type deserve the same treatment as colour: one base and one ratio, generating the whole scale, so the numbers relate to each other by construction rather than by taste.

A generated scale beats a typed one
base 4px, multipliers 0 .5 1 1.5 2 3 4 6 8 12 16 24

space-0  0        space-4  16px     space-12  48px
space-1  4px      space-6  24px     space-16  64px
space-2  8px      space-8  32px     space-24  96px

For type, a modular ratio (1.200, 1.250, 1.333 are the usual three) sets each step from the one below it. Ship line-height and letter-spacing with each step — Tailwind’s fontSize accepts a tuple, and a type scale that does not carry its leading is half a type scale.

fontSize with leading and tracking
fontSize: {
  "body-sm": ["0.875rem", { lineHeight: "1.5",  letterSpacing: "0" }],
  "body":    ["1rem",     { lineHeight: "1.5",  letterSpacing: "0" }],
  "h3":      ["1.5rem",   { lineHeight: "1.25", letterSpacing: "-0.01em" }],
  "h2":      ["1.875rem", { lineHeight: "1.2",  letterSpacing: "-0.02em" }],
}

Keeping arbitrary values out

Tailwind’s arbitrary-value syntax — mt-[13px], text-[#4F46E5] — is a genuinely useful escape hatch and the main way a system erodes. Two defences, both cheap:

  1. Lint it. eslint-plugin-tailwindcss can flag arbitrary values and classnames that do not exist in the theme. Warn, do not error, at first — a wall of errors on day one gets the rule disabled.
  2. Make the right thing available. Most arbitrary values appear because the scale genuinely had no step for the job. If mt-[13px] keeps recurring, the scale is wrong, not the developer.

Components on top of the theme

Utilities are the right level for layout and the wrong level for a button that appears four hundred times. Wrap recurring patterns in components with a variant API — cva or an equivalent — so the utility strings live in one file:

One place the button's classes exist
const button = cva("inline-flex items-center rounded-md font-medium", {
  variants: {
    variant: {
      primary: "bg-action text-on-action hover:opacity-90",
      subtle:  "bg-surface-raised text-fg hover:bg-surface-sunken",
      ghost:   "text-fg-muted hover:bg-surface-raised hover:text-fg",
    },
    size: { sm: "h-8 px-3 text-body-sm", md: "h-10 px-4 text-body" },
  },
});

Generating the config instead of writing it

Everything above is mechanical: the ramps, the roles, the scales, the radii, the shadows. Which means it is work better done by a generator than by hand — and, more to the point, work that has to be redone identically for every other destination the system feeds.

Arkitype writes the Tailwind config from the same model it writes the CSS custom properties, the Figma variables, the MUI theme and the documentation from. The dark-mode variables above are not transcribed into the config; both are compiled from one set of semantic roles, so the config and the stylesheet cannot fall out of step.

Checklist

  • Replace colors and the core scales; extend the rest.
  • Name theme keys for their role, never for their hue.
  • Back every colour key with a CSS variable in channel form, with <alpha-value>.
  • Restate variables in .dark; never fork the theme.
  • Generate spacing and type from a base and a ratio; ship leading with each step.
  • Lint arbitrary values, and fix the scale when they recur.
  • Check the resulting pairs for contrast in both modes — see the colour guide.

Build the system, not just the swatches

Arkitype does everything on this page from one brand colour — tiered tokens, semantic roles, contrast checked live against WCAG, and output for Figma, Tailwind, MUI and CSS that is all generated from the same source.

Keep reading