Tailwind Design Tokens: 41 Declared, 22 Values That Went Around Them
41 tokens across three of Tailwind v4's 19 namespaces, and 161 utilities that spell a value literally instead. 22 of those literals were already a token in the same stylesheet.
A design token is only a token if a utility resolves to it. This project's @theme block declares 41 of them; a census of the same codebase finds 161 utilities that spell a value literally instead, and 22 of those literal colour values are byte-for-byte identical to a token that was already sitting in the file.
That gap — declared versus resolved — is the thing worth measuring in a Tailwind v4 codebase, and it is not what most token guides talk about.
What a token is in Tailwind v4
In v4 there is no tailwind.config.js colour object. Tokens are CSS custom properties inside an @theme block, and declaring one does two things at once: it emits a real CSS variable, and it generates the utility classes that reference it.
/* src/app/globals.css */
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-primary: #465fff;
--color-gray-400: #98a2b3;
--color-text-color-tertiary: #98a2b3;
--breakpoint-2xsm: 375px;
}
--color-gray-400 there is what makes text-gray-400, bg-gray-400, border-gray-400/50 and every other colour utility with that name exist. The variable and the utility are the same declaration. That's the mechanism; what redeclaring a default name overrides, versus what inventing a new one adds, is covered in Tailwind colors and isn't repeated here.
Tailwind ships its own theme.css with 419 theme variables across 19 namespaces: --color-*, --font-*, --text-*, --font-weight-*, --tracking-*, --leading-*, --breakpoint-*, --container-*, --spacing, --radius-*, --shadow-*, --inset-shadow-*, --drop-shadow-*, --text-shadow-*, --blur-*, --perspective-*, --aspect-*, --ease-*, --animate-*. Those are the defaults. Your @theme block is a diff against them, not a replacement for them.
The census: 41 declared, across three namespaces
Everything this project declares:
| Namespace | Declared here | What it covers |
|---|---|---|
--color-* | 38 | primary 25–950, gray 25–950, 8 semantic names, success 50–700 |
--breakpoint-* | 2 | 2xsm 375px, xsm 425px |
--font-* | 1 | the sans stack |
| everything else | 0 | spacing, radius, shadow, type scale, easing, animation |
Sixteen namespaces at zero is not automatically a finding. Tailwind's defaults are themselves a token system, and inheriting --radius-lg or --spacing unchanged is a legitimate decision — arguably the correct default one. The question isn't how many namespaces you filled. It's whether the values your components actually use resolve to a token from either source.
So we counted the ones that don't.
The finding: 161 utilities that go around the system
An arbitrary value — text-[40px], bg-[#101828] — is Tailwind's escape hatch. It compiles to the same CSS as a utility but references nothing. Across src/components and src/app:
| Kind of arbitrary value | Count |
|---|---|
Sizing (max-w, w, h, min-w) | 63 |
Visual (text, bg, border, shadow, backdrop-blur) | 45 |
Spacing & position (p*, top, bottom, space-y) | 21 |
leading-[…] | 11 |
aspect-[…] | 10 |
z-[…] | 5 |
| Everything else | 6 |
| Total | 161 |
The 52 max-w-[…] usages inside that sizing row have already had their own audit — see Tailwind max width, which found the viewport band where our own layout narrows as it widens. This post is about the other rows, and specifically about the colours.
22 literals that were already tokens
Twenty-four of the 161 carry a colour — a hex literal, or an rgba() whose channels are one. Resolving each against the @theme block:
| Literal in the markup | Occurrences | Token it equals |
|---|---|---|
#98A2B3 | 10 | --color-gray-400 and --color-text-color-tertiary |
#F2F4F7 | 4 | --color-gray-100 and --color-stroke-secondary |
#101828 | 3 | --color-gray-900 |
#E4E7EC | 1 | --color-gray-200 and --color-stroke |
#ECFDF3 | 1 | --color-success-50 |
#465fff / #2a31d8 / #161950 | 1 each | --color-primary-500 / -700 / -950 |
Twenty-two occurrences. Every one of them had a token available at the moment it was written.
Only four distinct colours in the entire codebase have no token at all: #fbfcff (a page-background tint, twice), #38bdf8 and #52caff (a sky gradient in one hero decoration), and #a6afc3 (one drop shadow). Four genuine gaps, twenty-two unnecessary bypasses.
Three of those bypasses are worth showing, because they're each a different failure mode.
The plain one. text-[#98A2B3] appears seven times across the footer, SocialLinks, NewsletterForm and FooterLinkColumn. It could be text-gray-400. It reads identically in the browser and differently in a diff: change --color-gray-400 and all four of those files stay untouched.
The one hiding in a channel list. bg-[rgba(152,162,179,0.32)], three times. 152, 162, 179 is 0x98, 0xA2, 0xB3 — the same colour again, written in decimal because it needed an alpha. Tailwind v4 has syntax for exactly this: bg-gray-400/32. No grep for #98A2B3 will ever find these three.
The one inside a shorthand. Both of these are single arbitrary values that contain two token colours each:
shadow-[0px_0px_0px_1px_#E4E7EC,0px_18.824px_100px_0px_rgba(16,24,40,0.12)]
bg-[linear-gradient(135deg,#465fff_0%,#2a31d8_55%,#161950_100%)]
#E4E7EC is --color-stroke. rgba(16,24,40,…) is --color-gray-900. The gradient is the primary scale, three stops of it, spelled out. Rebrand the primary colour and that gradient keeps the old one — silently, because nothing about it looks like a colour reference.
Two smaller findings from the same file
A variable that isn't a token. Sitting just below the @theme block:
:root {
--background: #ffffff;
}
It's outside @theme, so it generates no utility. And grepping the whole of src/ for var(--background) and for bg-background returns nothing. It is a declaration that does nothing, which is the specific hazard of v4's syntax: a custom property inside @theme is a token, and the identical line four lines lower is a dead variable. Nothing warns you.
Keyframes without an animation token. The same file defines the marquee:
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
...but declares no --animate-marquee in @theme. So the one component that uses it has to spell the whole shorthand: animate-[marquee_40s_linear_infinite]. The keyframes are shared; the duration and easing are not, and a second marquee would have to retype them. One line — --animate-marquee: marquee 40s linear infinite; — turns that into animate-marquee.
The same pattern explains the 13 arbitrary font sizes (text-[40px] alone appears eight times). The design has a type scale; the scale was never declared, so it lives in the markup as pixel literals.
The rule we'd apply going forward
An arbitrary value is correct when the value is genuinely one-off — a 530/330 aspect ratio matching a specific asset, a calc(100vh-7rem) tied to one layout. It is a defect when the value is a design decision that appears more than once, because the second occurrence is where drift begins.
The cheap test, and the one that produced this post:
# every colour literal that bypasses a token
grep -rnoE '\[(#[0-9a-fA-F]{6}|rgba?\([0-9, .]+\))' src/components src/app --include=*.tsx
Run it against your own @theme values. In our case it returned 24 lines, and 22 of them were avoidable.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| A rebrand changes most of the UI and misses a handful of elements | Colours spelled as literals in arbitrary values | Grep for hex and rgba( inside […]; here that found 22 occurrences of colours that were already tokens |
A custom property in globals.css generates no utility | It's declared in :root, not in @theme | Only @theme declarations produce utilities — a :root variable is inert unless something reads it with var() |
| A translucent brand colour survives a token rename | It was written as rgba(r,g,b,a), so no hex grep finds it | Use the slash-opacity syntax against the token — bg-gray-400/32, not bg-[rgba(152,162,179,0.32)] |
| An animation's timing is duplicated at every call site | @keyframes exists but no --animate-* token does | Declare the full shorthand as an --animate-* token; the keyframes alone don't generate a utility |
A new utility like bg-primary silently doesn't exist after editing tokens | Tailwind v4 with Turbopack doesn't hot-reload @theme changes | Restart the dev server after editing token variables — nothing errors, the class just never generates |
| Two token names hold the same value and call sites pick at random | A scale token and a semantic token were both defined as the same hex | Point the semantic one at the scale one so there's a single source; the split between the two layers is covered in Tailwind colors |
Frequently asked questions
What are design tokens in Tailwind CSS v4?
CSS custom properties declared inside an @theme block. Each one emits a real variable and generates the utilities that reference it, which is why the config file went away — the token and the class are the same declaration. Tailwind ships 419 of its own across 19 namespaces; yours are a diff against those.
Do I need to declare tokens for spacing, radius and shadows?
Only where your design disagrees with Tailwind's defaults. Inheriting --spacing and --radius-* unchanged is a real token system, not an absence of one. The signal that you should declare some is finding the same literal value in arbitrary brackets more than once — 13 arbitrary font sizes here is a type scale asking to be declared.
Is using arbitrary values bad practice?
No — they're the correct tool for genuinely one-off values, and this codebase keeps 10 aspect ratios and a calc() height in them deliberately. It becomes a defect when the value is a repeated design decision. The distinguishing question is whether a designer would give the value a name.
How do I find the values in my codebase that bypass tokens?
Grep for [ followed by a hex or rgba( across your components, then compare each hit against your @theme values. It takes a minute and, in our case, found 22 occurrences of eight colours that were already declared in the same stylesheet.
Can tokens be changed at runtime? Yes — because they compile to genuine CSS custom properties, overriding them on a scoped selector re-themes everything below it without a rebuild. That's the mechanism behind multi-tenant theming in Tailwind v4, and it's precisely what an arbitrary value opts out of.
Templates in this post
ASoc Apex Admin carries five dashboards and a deep UI kit in one product — the scale at which a token census stops being optional, because a single unreferenced literal is repeated across dozens of screens. ASoc Clover Admin spans sales, finance and team dashboards plus email and chat modules, which is the case where semantic tokens (--color-stroke, --color-title-color) earn their keep over raw scale names. ASoc Crest Admin is a classic sidebar admin with a full component library, where the type scale and shadow values are the tokens most worth declaring before the library grows.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the sizing half of the same census, see Tailwind max width.
