The Tailwind class that never applied

Tailwind generated it, TypeScript validated it, ESLint said nothing. It simply had no effect. The bug was in the name I had chosen.

RM
Rabie Menad
3 min read

While adding a type scale to this site, I wrote a class that never changed a single pixel on screen.

No tool complained. Everything was green. I had to open the inspector to understand.

The starting point

Three font sizes were sitting hard-coded across the codebase: text-[11px], text-[10px], text-[0.8125rem]. Mundane goal: replace them with design system tokens.

Tailwind 4 is configured directly in CSS, so adding them is trivial:

@theme inline {
  --text-3xs: 0.625rem;
  --text-2xs: 0.6875rem;
  --text-xs-plus: 0.8125rem;
}

Three tokens, three replacements. pnpm typecheck, pnpm lint:ci, pnpm test, pnpm build: all green.

The symptom

Only one of the three changed nothing. The table of contents, meant to render its sub-headings at 13px, stayed at 14px.

The class was right there in the JSX:

className={cn(
  'text-sm transition-colors',
  entry.level === 3 && 'text-xs-plus ps-6',
)}

My first hypothesis, and why it was wrong

I assumed tailwind-merge was stripping the class, treating it as a duplicate of text-sm.

Reasonable. And wrong.

The DOM told a different story:

"relative flex items-baseline ... transition-colors text-sm ps-6"

ps-6 was there, so the condition evaluated correctly. But text-xs-plus was gone and text-sm had stayed. If tailwind-merge had done its deduplication job, it should have been the other way around.

The check that settles it

Rather than keep guessing, I asked the library directly:

node -e "
const { twMerge } = require('tailwind-merge');
console.log(twMerge('text-sm', 'text-xs-plus'));
console.log(twMerge('text-sm', 'text-2xs'));
"

Output:

text-sm text-xs-plus
text-2xs

The second line shows the expected behavior: text-2xs replaces text-sm.

The first strips nothing. It keeps both classes.

The explanation

tailwind-merge has to recognize that two classes belong to the same group before it can arbitrate between them. It knows the standard scale patterns (xs, sm, 2xs, 3xs), but xs-plus looks like nothing it can classify. Unable to identify it as a font size, it lets it through next to text-sm.

So both end up in the DOM, with identical CSS specificity. Stylesheet order decides, and Tailwind sorts its utilities by ascending size. Since text-sm (14px) is generated after text-xs-plus (13px), it wins.

The bug was not in Tailwind, nor in tailwind-merge. It was in the name I had chosen.

The fix

Rename the token so it follows a recognized pattern:

/* `2sm` (not `xs-plus`): tailwind-merge only treats the
   `<n>xs` / `<n>sm` shape as a font size */
--text-2sm: 0.8125rem;
--text-2sm--line-height: 1.5;

Verified in the browser this time:

getComputedStyle(h3).fontSize // "13px"
getComputedStyle(h2).fontSize // "14px"

What I took away

A name is never neutral

As soon as you use tailwind-merge, so cn(), so any shadcn/ui project, the name of a custom token becomes a technical constraint. It has to stay within the patterns the library can classify.

You think you are naming for humans. You are also naming for a parser.

No tool could have caught it

ToolWhat it checksVerdict
TypeScriptTypes, not CSS stringsSilent
ESLintCode rulesSilent
TailwindThe token exists, the class is emittedSilent
The buildCompilation succeedsSilent

Each one did its job correctly. None had the context: the question was not "does this class exist?" but "does this class win?".

Only the browser settles it

That is the real lesson, and it reaches well beyond Tailwind. A green suite proves the code compiles, not that it does what you think.

For CSS, the check is one line:

getComputedStyle(element).fontSize

Thirty seconds. Cheaper than the doubt.

RM
Rabie MenadFreelance & Entrepreneur

I design and ship high-performance web products with React & Next.js. Let's talk about your project.

More posts