Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

NamespaceDeclared hereWhat it covers
--color-*38primary 25–950, gray 25–950, 8 semantic names, success 50–700
--breakpoint-*22xsm 375px, xsm 425px
--font-*1the sans stack
everything else0spacing, 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 valueCount
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 else6
Total161

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 markupOccurrencesToken it equals
#98A2B310--color-gray-400 and --color-text-color-tertiary
#F2F4F74--color-gray-100 and --color-stroke-secondary
#1018283--color-gray-900
#E4E7EC1--color-gray-200 and --color-stroke
#ECFDF31--color-success-50
#465fff / #2a31d8 / #1619501 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

SymptomCauseFix
A rebrand changes most of the UI and misses a handful of elementsColours spelled as literals in arbitrary valuesGrep for hex and rgba( inside […]; here that found 22 occurrences of colours that were already tokens
A custom property in globals.css generates no utilityIt's declared in :root, not in @themeOnly @theme declarations produce utilities — a :root variable is inert unless something reads it with var()
A translucent brand colour survives a token renameIt was written as rgba(r,g,b,a), so no hex grep finds itUse 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 doesDeclare 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 tokensTailwind v4 with Turbopack doesn't hot-reload @theme changesRestart 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 randomA scale token and a semantic token were both defined as the same hexPoint 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.

Keep reading

Tutorial10 min read

Tailwind Font Weight: 9 Named Steps, 4 This Codebase Uses

193 font-weight utility calls across this codebase, spanning only 4 of Tailwind's 9 named steps — traced to two atoms that set the hierarchy once.

Read more