On This Page
Right there in the class attribute. Nowhere in the stylesheet.
jsx
function StatusPill({ status, children }) {
return (
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium bg-${status.color}-100 text-${status.color}-700`}>
{children}
</span>
);
}Open DevTools, click the pill, and the class attribute is exactly what you'd expect: bg-purple-100 text-purple-700 sitting right there in the HTML. Check the Styles pane and there's nothing. Not a struck-through rule losing to something more specific. Nothing. The element falls back to whatever its parent's background happens to be, and getComputedStyle reports the inherited value like the class was never written.
I hit this on a support-ticket dashboard last spring. Five status colors (open, pending, resolved, escalated, archived) all rendered fine. We added a sixth, "waiting on customer," gave it purple as its color token, and the pill shipped with no background and no text color at all. Same component, same pattern, same build. Four colors worked. One didn't. That inconsistency is what actually burned the day. If every color had failed, I'd have suspected the build immediately. Because five out of six worked, I went looking everywhere except the right place.
A repro that fails in six lines of JSX
Here's the whole thing, stripped down to a file you can run yourself.
bash
npm create vite@latest repro -- --template react-ts
cd repro
npm install
npm install tailwindcss @tailwindcss/vitevite.config.ts:
ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})src/index.css:
css
@import "tailwindcss";src/App.tsx:
tsx
import './index.css'
const statuses = [
{ label: 'Open', color: 'blue' },
{ label: 'Waiting on customer', color: 'purple' },
]
export default function App() {
return (
<div className="p-8 space-y-2">
{statuses.map((s) => (
<span
key={s.label}
className={`inline-block rounded-full px-3 py-1 text-sm bg-${s.color}-100 text-${s.color}-700`}
>
{s.label}
</span>
))}
</div>
)
}Run npm run dev, open the page, and both pills look identical: plain text, no pill shape color, no background. It's not a dev-vs-build thing here. This repro fails the same way in npm run build && npm run preview, because nothing about this bug depends on the mode you're running in. It depends on what string literally exists in your source file, and bg-${s.color}-100 isn't a class name. It's a template.
Now change one thing: add bg-blue-100 as a literal string anywhere else in the file, even in a comment, and the blue pill starts working while purple still doesn't. That's the whole bug in one edit.
Which Tailwind version are you actually scanning with?
This isn't a browser compatibility problem. Nothing here depends on the engine rendering the page. It depends on which version of Tailwind is turning your source files into CSS, because each version does that scanning differently.
| Version | How it finds classes | Where you fix a miss |
|---|---|---|
| Tailwind 2.x | PurgeCSS regex over the purge config glob | purge.safelist |
| Tailwind 3.x (JIT, default since 3.0) | Regex scan over the content config glob | safelist in tailwind.config.js |
| Tailwind 4.x | Automatic heuristic scan, no config required | @source inline() in your CSS |
Every one of them, from PurgeCSS in v2 through the Rust-based engine in v4 (Tailwind's alpha announcement called it Oxide), does the same fundamental thing: read your files as text and look for strings shaped like class names. None of them run your JavaScript. So the fix pattern is the same across all three majors even though the config syntax changed twice.
The one place browser support actually matters here: if you're on Tailwind v4 and using its default color palette, those colors are defined with oklch(), and the framework's own compatibility notes name Safari 16.4+, Chrome 111+, and Firefox 128+ as the floor, because v4's generated CSS leans on @property and color-mix() too. All three of those features cleared the "widely available" bar a while ago, so unless you're supporting something like an old embedded WebView, that's not what's failing here. Worth checking with @supports (color: oklch(0 0 0)) if you genuinely need to know.
What's coming: there's an open discussion on the Tailwind repo about generating more of this automatically without @source inline(), but nothing's shipped as I'm writing this, so treat the workaround below as durable, not temporary.
The short answer
Tailwind doesn't execute your code. It reads it as plain text and looks for tokens that already look like complete class names. bg-${status.color}-100 is not a complete class name at scan time, it's a template literal with a hole in the middle, so no CSS ever gets generated for it. The five colors that "worked" only worked because their full class names happened to already exist somewhere else in the codebase, as static strings Tailwind could actually see.
How the class scanner actually reads your file
This is worth understanding properly, because it explains every variant of this bug you'll hit later, not just this one.
Tailwind's own docs put it plainly: it treats your source as plain text and "doesn't attempt to actually parse your files as code in any way." It just looks for character sequences that fit the shape of a class name (letters, digits, hyphens, colons for variants, brackets for arbitrary values) and tries to generate CSS for every sequence it finds. Anything that doesn't map to a real utility gets silently discarded. No error, no warning. That silence is the whole reason this bug is so hard to notice: a typo and a template literal fail identically, by producing nothing.
Think of it less like a compiler reading your program and more like someone skimming your file with a highlighter, marking anything that looks class-shaped. A highlighter doesn't know what a variable is. It can't resolve ${status.color} into purple because it isn't running your code, just scanning the characters on the page. When the scanner hits ${, that's not a character it expects inside a class name, so the token breaks there. You end up with fragments like bg- and -100 floating around, neither of which is a utility Tailwind recognizes, and both get thrown away.
This is also why @apply, arbitrary values, and variants all still work fine inside this system: bg-[#7c3aed], hover:bg-purple-600, md:bg-purple-100 are all still complete, literal strings in your source. The scanner doesn't care how weird the class looks. It cares whether the whole thing is sitting there as text it can read start to finish.
The generated stylesheet itself still goes through Lightning CSS afterward for nesting, prefixing, and minification. That part's a completely separate step, downstream of the scan, and it's not where this bug lives. By the time Lightning CSS runs, the decision about which utilities exist has already been made.
Chasing it through content globs and @source
Two wrong turns before I found it, both worth mentioning because they're exactly what you'll try first too.
Wrong turn one: I assumed it was a cascade problem. Some other rule beating mine, maybe from a reset or a third-party component library loaded after Tailwind's own CSS. I opened the Styles pane expecting to see my bg-purple-100 rule crossed out by something with higher specificity. It wasn't crossed out. It wasn't there. No rule, active or inactive, referencing that class existed anywhere in the loaded stylesheet. That's the tell that rules this whole category out: a losing cascade fight leaves a visible, struck-through loser. A missing utility leaves nothing to strike through, because it was never generated in the first place.
Wrong turn two: I assumed it was a content-path problem. This one's reasonable, because it used to be the most common cause back in Tailwind 2 and early 3, when you had to hand-maintain the content glob and it was easy to miss a directory. I went and added ./src/**/*.{ts,tsx} explicitly to the config, rebuilt, and got nothing. Of course I did. The file with the pill component was already inside the scanned path. The problem was never which files got scanned. It was what the scanner found once it got there.
Here's the path that actually worked, in order:
- Open the compiled stylesheet directly. In dev, that's the injected
<style>tag or the CSS file Vite serves; after a build, it's whatever landed indist/assets/*.css. Search it for the literal string you expect.
bash
# after a production build
grep -o "bg-purple-100" dist/assets/*.css || echo "never generated"If that prints "never generated," you've confirmed it: this is a generation problem, not a specificity or load-order problem, and you can stop looking at your CSS files entirely.
- Confirm from the DOM side too, so you're not chasing a stale build:
js
// on the broken element, in the DevTools console
const el = $0
console.log(getComputedStyle(el).backgroundColor)
// browser default / transparent / inherited-from-parent means
// no rule matched, not that a rule lost- Grep your own source for the interpolation, not the config:
bash
grep -rn 'bg-\${' src/
grep -rn 'className={`' src/ | grep '\${'Anywhere this matches is a candidate for the same bug, whether or not it's failing yet. Remember, it can work by coincidence if that exact class string happens to exist elsewhere.
Ranked by how often each one is actually the cause, in my experience and in the pile of Tailwind GitHub discussions on this exact symptom: template-literal interpolation is first by a wide margin, a missing @source entry for a workspace package under node_modules (which Tailwind excludes by default) is second, and an actual content-path gap is a distant third now that v4's automatic detection covers most of what used to need manual config.
The fix
The direct fix is to stop asking Tailwind to read a variable. Give it complete, literal strings instead, and resolve the variable in JavaScript the way you'd resolve any other lookup.
diff
- <span className={`bg-${status.color}-100 text-${status.color}-700`}>
+ <span className={statusStyles[status.color]}>tsx
const statusStyles: Record<string, string> = {
blue: 'bg-blue-100 text-blue-700',
yellow: 'bg-yellow-100 text-yellow-700',
green: 'bg-green-100 text-green-700',
red: 'bg-red-100 text-red-700',
gray: 'bg-gray-100 text-gray-700',
purple: 'bg-purple-100 text-purple-700',
}Every value in that object is a full, literal string, so the scanner reads them exactly the way it read the five that were already "accidentally" working. That's the fix I shipped, and I'd ship it again.
Two other options exist, ranked by how much I'd trust them:
Safelist the specific classes, either the v4 way in CSS or the v3 way in config, when a lookup map genuinely doesn't fit your structure:
css
/* v4, in your CSS entry file */
@import "tailwindcss";
@source inline("{bg-,text-}{blue,yellow,green,red,gray,purple}-{100,700}");js
// v3, in tailwind.config.js
module.exports = {
safelist: [
{ pattern: /(bg|text)-(blue|yellow|green|red|gray|purple)-(100|700)/ },
],
}This works, and it's honest about what's happening: you're telling the compiler "generate these even though you'll never see them written out." I don't love it as a default, because now the color list is duplicated between your component and your CSS, and it's exactly the kind of duplication that drifts the first time someone adds a seventh status and forgets the safelist.
Reach for @source not if the miss is a workspace package, not a template literal. If your pill component actually lives in a shared UI package under packages/ui, and your app's node_modules symlink to it gets excluded by Tailwind's default .gitignore-aware detection, the fix is registering that path explicitly:
css
@import "tailwindcss";
@source "../../packages/ui/src";That's a different bug wearing the same symptom, worth checking early with the grep from the previous section, since the fix is completely different.
What I wouldn't do: don't reach for !important, inline style={{ backgroundColor: ... }} as a permanent patch, or widening the content glob to **/* "just in case." The first two just paper over a scanner limitation that's going to bite the next dynamic class someone writes; the third slows every build down scanning files that were never the problem.
Building components so the scanner can always see the class
The lookup-map fix above solves this one component. The better fix is picking a shape for variant components that makes this class of bug structurally hard to write in the first place.
If you're building more than one or two variant components, pull in something like class-variance-authority or tailwind-variants instead of hand-rolling lookup objects everywhere. They're built entirely around the same idea, every variant is a complete, literal class string declared up front, but they give you type-checked props and a consistent API across your whole design system, instead of a slightly different Record<string, string> in every file.
And for the genuinely unbounded case (a user picking an arbitrary color from a color picker, not one of six fixed statuses), don't try to force that through utility classes at all. Set a CSS custom property at runtime and reference it with an arbitrary-value utility, which is still a complete literal string as far as the scanner's concerned:
tsx
<span
style={{ '--pill-color': userColor } as React.CSSProperties}
className="bg-[var(--pill-color)] rounded-full px-3 py-1"
>bg-[var(--pill-color)] is written out in full in your source, so it generates every time, and the actual color comes from wherever you set the custom property. This is the one place I'd tell you to stop fighting the tool and hand the truly dynamic part back to CSS, where it belongs.
What I added to CI so this can't ship again
eslint-plugin-tailwindcss, specifically theno-custom-classnamerule. It checks every class-shaped string against Tailwind's real utility set and flags anything that isn't one, including the broken fragments a template literal leaves behind, like a barebg-with nothing after it. It catches this at the pull request, not after a designer notices a blank pill in staging.- A build-time smoke check that greps the compiled CSS for a fixed list of "must exist" classes, one per design-system variant, and fails the build if any are missing. Cheap, five lines in a
postbuildscript, and it would have caught the purple pill before it shipped. - A Playwright visual regression pass across your variant set specifically, not just your pages. Screenshot every status pill color side by side; a blank one stands out immediately in a diff in a way it doesn't in a code review.
- Renovate or Dependabot on the Tailwind major, so a v3-to-v4 bump doesn't silently change your
content/safelistconfig into dead config nobody's reading anymore. - The Tailwind CSS IntelliSense VS Code extension won't help here, worth knowing so you don't rely on it. It autocompletes literal classes beautifully and does nothing for a template literal with a variable inside it. Same blind spot as the compiler, for the same reason.
Worth pinning
Tailwind scans text, not code. No exceptions, no version of the tool has ever executed your JavaScript to resolve a variable.
A class that "sometimes works" is a stronger signal than one that never does: it means the string exists somewhere else in your source by coincidence, not that your logic is fine.
Grep the compiled CSS before you touch the cascade. A missing rule and a losing rule look similar in the DOM and completely different in the stylesheet.
Lookup objects beat template literals for variant props; safelists are a valid fallback, not a first choice, because they duplicate a list that will drift.
For truly unbounded dynamic values, stop asking the utility scanner to do a runtime's job. Hand it to a CSS custom property instead.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.