Design Tokens & Design Systems
A design system is the shared set of decisions that makes every screen of a product look like it belongs together: which blue is "our" blue, how much space sits between a label and its input, how big a page heading is. A design token is one of those decisions with a name, such as --color-primary or --text-heading. Designers keep tokens in their design tool, and developers keep the same names in code.
Tailwind's default theme is already a well-made token set, but a company product needs its own. In this lesson you will organise tokens in layers, limit the scales so the team stays consistent, build a type scale with roles, and publish a token page that designers and developers can both check.
Three layers of tokens
Large teams split tokens into layers so that a brand change touches one place:
| Layer | Example | Answers the question |
|---|---|---|
| Primitive | --color-indigo-600, --spacing | What colours and sizes exist at all? |
| Semantic | --color-primary, --color-surface, --color-danger | What is this colour for? |
| Component | --radius-button, --shadow-card | How does one component use the system? |
Primitives are the raw palette; Tailwind's built-in colours are primitives. Semantic tokens point at primitives and are what your HTML uses. When the brand blue changes from indigo to sky, you change one semantic line, and every bg-primary on every page follows.
@import "tailwindcss";
@theme {
/* semantic: what each colour is for */
--color-primary: var(--color-indigo-600);
--color-primary-hover: var(--color-indigo-700);
--color-on-primary: var(--color-white);
--color-surface: var(--color-white);
--color-muted: var(--color-slate-500);
--color-danger: var(--color-red-600);
/* component */
--radius-button: 0.5rem;
--shadow-card: 0 1px 3px rgb(0 0 0 / 0.08);
}Notice the colour pair primary and on-primary: "on" names the text colour that is readable on top of that background. Checking contrast once for each pair means every button made from them passes. Run this example, then change indigo to emerald in the two primary lines.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Semantic tokens</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
<style type="text/tailwindcss">
@theme {
--color-primary: var(--color-indigo-600);
--color-primary-hover: var(--color-indigo-700);
--color-on-primary: var(--color-white);
--color-surface: var(--color-white);
--color-canvas: var(--color-slate-100);
--color-muted: var(--color-slate-500);
--color-danger: var(--color-red-600);
--radius-button: 0.5rem;
--shadow-card: 0 1px 3px rgb(0 0 0 / 0.08);
}
</style>
</head>
<body class="bg-canvas p-6">
<article class="max-w-sm rounded-xl bg-surface p-5 shadow-card">
<h1 class="text-lg font-semibold">Jaipur Coaching Centre</h1>
<p class="mt-1 text-sm text-muted">Batch 12, starts 3 December</p>
<p class="mt-3 text-sm text-danger">Only 4 seats left</p>
<div class="mt-4 flex gap-2">
<button class="rounded-button bg-primary px-4 py-2 font-semibold text-on-primary hover:bg-primary-hover">Reserve seat</button>
<button class="rounded-button px-4 py-2 font-semibold text-primary ring-1 ring-primary">Syllabus</button>
</div>
</article>
</body>
</html>The HTML never says "indigo", so the brand change is a one-line edit. For dark mode, semantic tokens get a second set of values; Dark Mode shows that with @theme inline, and Theming Multi-Brand Apps takes it further.
Limit the scales
Tailwind 4.3 gives you 286 palette colours (26 families of 11 shades) and a spacing number for almost any size. That freedom leads to drift: one developer uses p-3, another p-3.5, a third p-4, and the product slowly looks untidy. A design system chooses a smaller set.
You can enforce this in the theme. Setting a namespace to initial removes the defaults; --spacing: initial removes the numeric spacing scale. We compiled this with the real CLI:
@theme {
--spacing: initial;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--color-*: initial;
--color-primary: oklch(52% 0.15 250);
--color-on-primary: #fff;
}With the classes p-4 p-sm p-md gap-lg bg-sky-500 bg-primary mt-[13px] in the templates, the output contained only these utilities:
.mt-\[13px\] { margin-top: 13px; }
.gap-lg { gap: var(--spacing-lg); }
.bg-primary { background-color: var(--color-primary); }
.p-md { padding: var(--spacing-md); }
.p-sm { padding: var(--spacing-sm); }p-4 and bg-sky-500 simply produce nothing, and IntelliSense stops suggesting them. Two things to notice. First, arbitrary values like mt-[13px] still work, so agree as a team to avoid them except for real one-offs. Second, removing the whole spacing scale is strict; many teams keep Tailwind's 4px-based numbers (--spacing: 0.25rem) and limit only colours and font sizes. Choose what your designers actually specify.
A type scale with roles
Instead of picking text-2xl font-bold tracking-tight by hand for every heading, define type roles. A --text-* token can carry its own line height, weight and letter spacing with double-dash suffixes:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Type roles</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
<style type="text/tailwindcss">
@theme {
--text-display: 2.5rem;
--text-display--line-height: 1.1;
--text-display--font-weight: 800;
--text-display--letter-spacing: -0.02em;
--text-title: 1.25rem;
--text-title--line-height: 1.4;
--text-title--font-weight: 600;
--text-body: 1rem;
--text-body--line-height: 1.6;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.4;
}
</style>
</head>
<body class="max-w-lg p-6 text-slate-800">
<p class="text-caption text-slate-500">Class 10 board results</p>
<h1 class="text-display">Meera scores 96.4%</h1>
<h2 class="mt-6 text-title">Subject-wise marks</h2>
<p class="mt-2 text-body">Meera from Kochi topped her school in science and mathematics, and plans to study engineering.</p>
</body>
</html>One class, text-display, now sets four properties. If you add font-medium or leading-tight on an element, those still win, because the generated rule reads the token through a fallback (var(--tw-font-weight, var(--text-display--font-weight))).
A token page for the team
A design system is only useful if people can see it. A small page that lists the tokens helps designers check the code matches their files. Normally Tailwind only outputs variables that something uses; @theme static outputs all of them, so JavaScript can read them:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Token page</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
<style type="text/tailwindcss">
@theme static {
--color-primary: oklch(52% 0.15 250);
--color-on-primary: #ffffff;
--color-surface: #ffffff;
--color-muted: oklch(55% 0.03 260);
--color-danger: oklch(58% 0.21 27);
}
</style>
</head>
<body class="p-6">
<h1 class="text-xl font-bold">Colour tokens</h1>
<ul id="tokens" class="mt-4 grid max-w-xl gap-3 sm:grid-cols-2"></ul>
<script>
const names = ['primary', 'on-primary', 'surface', 'muted', 'danger'];
const styles = getComputedStyle(document.documentElement);
const list = document.getElementById('tokens');
setTimeout(() => {
for (const name of names) {
const value = styles.getPropertyValue(`--color-${name}`).trim();
const item = document.createElement('li');
item.className = 'flex items-center gap-3 rounded-lg border border-slate-200 p-3';
const swatch = document.createElement('span');
swatch.className = 'size-10 shrink-0 rounded-md border border-slate-300';
swatch.style.background = value;
const label = document.createElement('code');
label.className = 'text-sm';
label.textContent = `--color-${name}: ${value}`;
item.append(swatch, label);
list.append(item);
}
}, 300);
</script>
</body>
</html>In a real project, keep the token list in one file (tokens.css) and build this page from it. Many teams also export tokens from their design tool as JSON and generate tokens.css with a script, so the names can never drift apart.
Common mistakes
- Naming tokens by colour, not purpose.
--color-bluebreaks when the brand turns green;--color-primarydoes not. - Semantic tokens without pairs. Define
on-primary,on-dangerand so on, and check contrast once for each pair. - Removing the spacing scale without asking designers. Only restrict what the design actually fixes.
- Letting arbitrary values creep in.
p-[13px]bypasses the system; reserve brackets for true one-offs.
Key points
- Design tokens are named design decisions; Tailwind 4 stores them as
@themevariables. - Use layers: primitive colours, semantic tokens named by purpose, and a few component tokens.
- Pair backgrounds with readable text tokens such as
primaryandon-primary. --color-*: initialand--spacing: initialremove defaults to keep a team on the agreed scale.--text-*tokens can include--line-height,--font-weightand--letter-spacingto create type roles.@theme staticoutputs every token so a documentation page or script can read them.
Exercise
Create a small token set for a hospital appointment app in Lucknow. Define semantic colours primary, on-primary, surface, muted, success and on-success (pointing at Tailwind primitives), a --radius-control token, and two type roles: text-heading (1.5rem, weight 700) and text-label (0.875rem, weight 500). Use them on a card that shows "Dr. Isha Rao, cardiology", a "Confirmed" badge and a "Reschedule" button.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hospital tokens</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
<style type="text/tailwindcss">
@theme {
--color-primary: var(--color-teal-700);
--color-on-primary: var(--color-white);
--color-surface: var(--color-white);
--color-muted: var(--color-slate-500);
--color-success: var(--color-green-100);
--color-on-success: var(--color-green-800);
--radius-control: 0.5rem;
--text-heading: 1.5rem;
--text-heading--font-weight: 700;
--text-label: 0.875rem;
--text-label--font-weight: 500;
}
</style>
</head>
<body class="bg-slate-100 p-6">
<article class="max-w-sm rounded-xl bg-surface p-5">
<span class="rounded-full bg-success px-3 py-1 text-label text-on-success">Confirmed</span>
<h1 class="mt-3 text-heading">Dr. Isha Rao</h1>
<p class="text-label text-muted">Cardiology, Tuesday 10:30 a.m.</p>
<button class="mt-4 rounded-control bg-primary px-4 py-2 text-label text-on-primary">Reschedule</button>
</article>
</body>
</html>The markup only uses purpose names, so changing the hospital's brand colour means editing one line in @theme.
Quick Quiz
1. Which token name is a semantic token?
Semantic tokens describe purpose; --color-indigo-600 is a primitive palette colour.
2. What happens to p-4 after --spacing: initial; in @theme?
Removing the base spacing value removes the numeric spacing utilities; only named keys you define remain.
3. What does --text-title--font-weight: 600; do?
Double-dash suffixes on a --text-* token add line height, weight or letter spacing to the same utility.
4. Why define on-primary next to primary?
Colour pairs keep every component that uses them readable without checking each one.
Learn Tailwind CSS with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
