The Tailwind class that never applied

Tailwind generated it, TypeScript validated it, ESLint said nothing. It simply had no effect. A reminder that the only proof lives in the browser.

RM
Rabie Menad
3 min read

While adding a custom type scale to this site, I wrote a class that never produced a single pixel of difference. No tool complained. The bug was only visible by inspecting the DOM.

The starting point

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

Tailwind 4 is configured directly in CSS, which makes adding them 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 on screen. The table of contents, meant to render its sub-headings at 13px, stubbornly stayed at 14px.

Yet the class was right there in the JSX:

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

What I thought was happening

My first hypothesis was that 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 present, so the condition evaluated correctly. But text-xs-plus was gone, and text-sm had stayed.

The check that settles it

Rather than keep guessing, a direct test of the library:

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 is the expected behaviour: text-2xs replaces text-sm. The first strips nothing at all: it keeps both classes.

The real explanation

tailwind-merge needs to know that two classes belong to the same group in order to arbitrate between them. It recognises the standard scale patterns (xs, sm, 2xs, 3xs), but xs-plus looks like nothing it knows. Unable to identify it as a font size, it lets it through alongside text-sm.

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

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

The fix

Rename the token so it follows a recognised 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

Naming a token isn't free

As soon as you use tailwind-merge, so as soon as you use cn(), so on 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.

It's counterintuitive: you think you're naming for humans, when you're 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 necessary context: the question wasn't "does this class exist?" but "does this class win?".

The proof is in the browser

That's the real lesson, and it reaches well beyond Tailwind. A green suite proves the code compiles, not that it produces the intended result. For CSS, the check is one line:

getComputedStyle(element).fontSize

Thirty seconds. That's the price of certainty, and it's far below the price of 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