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:
| Scale | Replace or extend | Why |
|---|---|---|
| colors | Replace | Leaving the defaults in means bg-sky-500 is always one keystroke away, and it will be used. |
| spacing | Replace | Your scale is the contract. Two scales is no scale. |
| fontSize | Replace | Type steps carry line-height and tracking with them; the defaults do not match your ratio. |
| borderRadius | Replace | A small named set — none, sm, md, lg, full — is what makes shape consistent. |
| screens, fontFamily | Extend | The 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.
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>)",
},
},
};: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.
@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.
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 96pxFor 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: {
"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:
- Lint it.
eslint-plugin-tailwindcsscan 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. - 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:
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
colorsand 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.