Why your Tailwind classes aren't showing up

The class is right there in the DOM and nothing happens. Here's why Tailwind never saw it, and four fixes that actually hold up in production.

css tailwind-css vite javascript frontend build-tools React
Bharath
Reading Progress

On This Page

The class is right there in the DOM. So why does nothing happen?

Picture this: you open DevTools, click the element, and there it is, sitting in the class attribute exactly as you wrote it in the JSX. bg-red-600. Not a typo. Not a stray space. And the element is still the same grey box it was before you added it.

That's the shape of this bug every time. It's not a syntax error. It's not a specificity fight you're losing. The class you wrote is genuinely, correctly, present on the element, and there is simply no rule anywhere on the page that matches it.

I hit this on a promo banner component a couple of years back. Marketing wanted to control the accent color from the CMS, so the banner took a color field and built a class string from it: bg-${color}-600. Worked perfectly in the CMS preview, because the preview ran the dev server with every color someone had tried that week already sitting in the compiled CSS from earlier edits. Shipped to production, cache cleared, and half the banners were plain grey. That gap between "looks fine locally" and "broken in prod" is the whole story of this bug, and it's worth understanding once so it stops costing you an afternoon every time marketing asks for one more dynamic prop.

This one applies whether you're on Tailwind v3 or v4. The mechanism is the same in both; only the scanning machinery changed.

Three files, no styling

Here's the smallest repro I could get down to. It's a Vite + React app, Tailwind v4, and it fails exactly the way the banner did.

bash

npm create vite@latest repro -- --template react-ts
cd repro
npm install
npm install tailwindcss@4.3.3 @tailwindcss/vite@4.3.3

vite.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, the version that fails:

tsx

function Banner({ color }: { color: 'red' | 'green' | 'blue' }) {
  return (
    <div className={`bg-${color}-600 text-white p-4 rounded`}>
      Sale ends Friday
    </div>
  )
}

export default function App() {
  return <Banner color="red" />
}

bash

npm run dev

Open it, and the banner renders with no background at all, just default black text on white. npm run build && npm run preview gives you the same result, so this isn't a dev-server quirk. The class bg-red-600 is sitting in the compiled HTML. It just doesn't exist in dist/assets/index-*.css. You can grep for it and confirm:

bash

npm run build
grep -c "bg-red-600" dist/assets/*.css
# 0

If you're on an older codebase still running Tailwind v3, the repro is the same shape with a content array in tailwind.config.js instead of the Vite plugin, and the same bg-${color}-600 pattern in a component. Same failure, same reason. The scanning code changed between versions; the rule that catches you didn't.

v3 versus v4, and what actually changed

This one isn't a browser bug at all. Once the CSS is compiled, every engine renders it identically: Chrome, Firefox, Safari, it doesn't matter. The failure happens entirely at build time, before any browser is involved, so there's no compatibility table to check here. What's worth comparing instead is how the two major Tailwind versions decide which classes to generate, because the fix looks slightly different depending on which one you're on.

Tailwind v3 (still maintained, v3.4.19, EOL ~Feb 2027)Tailwind v4 (current: 4.3.3)
How it finds classesYou list globs in content: [] in tailwind.config.jsAutomatic, scans your project from the CSS file's location outward, no config needed
IgnoresOnly what you don't list.gitignore entries, node_modules, binaries, lockfiles, CSS files
Registering an extra pathAdd to the content array@source "../path" in your CSS
Opting a dependency inAdd its path to content@source "../node_modules/@acme/ui-lib" (needed because node_modules is skipped by default)
Forcing specific classes to existsafelist in config, with string or regex pattern entries@source inline("..."), with brace-expansion for ranges and variants
Dynamic string constructionNot detected (same underlying limitation)Not detected (same underlying limitation)

Same failure mode, different plumbing. If you're mid-migration from v3 to v4, this is one of maybe four things that break, and it's the one where the config change is real but the underlying rule you're tripping over hasn't moved at all.

One more spot this bites: monorepos. A shared component library living in node_modules (or in a workspace package Tailwind treats the same way) gets skipped by default in both versions, so classes used only inside that package's components silently disappear from consuming apps until you add an explicit @source or content entry for it. That's a different bug from the one this article's about, but it produces the exact same "class is on the element, no rule matches it" symptom, so it's worth ruling out early if your setup involves a design-system package.

Tailwind never saw that string

Here's the one-paragraph version, and it's worth reading twice because most people's intuition about how Tailwind works is wrong in a way that only shows up with dynamic classes: Tailwind doesn't run your JavaScript. It doesn't know what color will be at runtime. It reads your source files as plain text and looks for tokens that look like class names: literal, complete, spelled-out strings. bg-${color}-600 isn't one of those. The three characters ${c aren't a class name in any color. bg-red-600 and bg-green-600 exist as complete strings somewhere in your app's logic, sure, but not as a literal substring in any file Tailwind reads. So it generates nothing for them, and the class you render at runtime matches nothing in the stylesheet.

How the scanner actually works

This is the part that trips people up, because it sounds like it should be smarter than it is. Tailwind, in both v3 and v4, is not a JavaScript interpreter. It never executes your component code. In v4 specifically, the docs are blunt about this: it "treats all of your source files as plain text, and doesn't attempt to actually parse your files as code in any way." It walks the files, applies something close to a regex for "sequence of characters that could plausibly be a utility class," and generates CSS for every distinct token it finds that matches a real utility in its rule set.

That's a deliberate trade-off, not an oversight. A real parser for every language Tailwind gets used in (JSX, Vue SFCs, Svelte, Razor, Blade, plain HTML, ERB) would be enormous and would still miss the exact same category of bug, because the problem isn't parsing fidelity. It's that the string doesn't exist until the browser is running. As one of the maintainers put it plainly on a GitHub discussion about this exact symptom: Tailwind compiles at build time, and "by the time your app is running at runtime, Tailwind is no longer part of the running code." There's no hook to call back into. The compiler has already finished and gone home.

Which is why the fix is never "make the scanner smarter." It's "make the string exist somewhere Tailwind actually reads." I'll get to what that looks like, but the mental model to keep is this: Tailwind's build step is a bouncer holding a printed guest list. It'll wave through anyone whose full name is already written on that list. Whisper half a name and point at the rest, bg- plus a variable it can't read, and it doesn't matter how real the person is standing in front of it. The name has to be on the page, spelled out in full, before the door opens.

The wrong turns I took first

Two theories ate most of that afternoon before I found the real one, and I'd bet either one is your first guess too.

Theory one: it's a specificity problem. Some other rule is beating mine. I opened the Styles pane expecting to see bg-red-600 struck through, losing to something with higher specificity or a later cascade layer. It wasn't there at all: not winning, not losing, not present as a crossed-out declaration, nothing. That's the tell that rules this theory out immediately: a specificity loss shows you the losing rule with a line through it. A missing rule shows you nothing, because there's nothing to show.

Theory two: it's a caching problem. Vite's dev server caches an optimized dependency graph in node_modules/.vite, and stale caches cause plenty of legitimately weird styling bugs. I deleted that directory, hard-reloaded with caches disabled, even restarted the dev server cold. Still grey. What killed this theory for good was running an actual production build (npm run build && npm run preview) and finding the CSS missing there too. A caching bug doesn't survive a full clean build from scratch. This one did, which meant it was never about staleness.

Once both of those were off the table, the only place left to look was the compiled stylesheet itself, which is where the real check lives:

js

// Run in the browser console: does a matching rule exist ANYWHERE
// in any loaded stylesheet for this class?
function classExists(cls) {
  for (const sheet of document.styleSheets) {
    let rules
    try { rules = sheet.cssRules } catch { continue } // cross-origin sheet, skip
    for (const rule of rules) {
      if (rule.selectorText?.includes(`.${CSS.escape(cls)}`)) {
        return { found: true, href: sheet.href, css: rule.cssText }
      }
    }
  }
  return { found: false }
}
classExists('bg-red-600')
// { found: false }

That's the tell, plainly: found: false for a class that's visibly sitting in the DOM. Once you see that, you're not debugging CSS anymore. You're debugging which source files Tailwind actually reads, which is a build-config question, not a styling one.

Ranked by how often each one is actually the cause, in my experience: the dynamic-string-construction case from this article is the most common by a wide margin, then the node_modules/monorepo path issue from the matrix above, then a genuinely missing @source/content glob for a folder that isn't in the default scan path, and trailing well behind, an actual specificity bug that just happened to look like this at first glance. The banner was the first one. It's usually the first one.

The fix

Ranked best-first, because two of these four are really just ways of making the symptom go away without fixing anything.

1. Map to complete, static strings (what I'd ship).

tsx

const colorVariants = {
  red: 'bg-red-600 hover:bg-red-500',
  green: 'bg-green-600 hover:bg-green-500',
  blue: 'bg-blue-600 hover:bg-blue-500',
} as const

function Banner({ color }: { color: keyof typeof colorVariants }) {
  return (
    <div className={`${colorVariants[color]} text-white p-4 rounded`}>
      Sale ends Friday
    </div>
  )
}

Every string Tailwind needs to see is now written out, in full, somewhere in the file. No construction, no interpolation into the utility name itself. This is the fix the Tailwind team points people toward every time this comes up, and it's the one I'd actually put in a component that ships.

2. Push the dynamic part into a CSS variable, keep the class name static.

tsx

<div
  className="bg-(--accent) text-white p-4 rounded"
  style={{ '--accent': colorToHex(color) } as React.CSSProperties}
>
  Sale ends Friday
</div>

bg-(--accent) is v4's shorthand for bg-[var(--accent)] (the older bracket form still works if you're not on 4.1+). The class name itself, bg-(--accent), is a complete, static token, so it compiles every time. The actual color comes in through an inline custom property at runtime, which CSS has always supported. This is the right call when the value genuinely comes from somewhere you don't control at build time — a CMS color picker, a user theme, a chart's per-series colors.

3. Safelist a known, finite set.

css

@import "tailwindcss";
@source inline("{hover:,}bg-{red,green,blue}-600");

Fine for something like a fixed set of five status colors that will never grow. The honest cost: every permutation you list gets generated whether it's used or not, so this scales badly, and it's easy to forget to update when someone adds a sixth status six months later. I'd reach for option 1 before this one in almost every case. It's not meaningfully more typing and it doesn't rot.

4. Precompute at write time instead of read time.

If the classes are coming from a CMS, generate the final class string when the content is saved, not when the page renders. Store "bg-red-600 hover:bg-red-500" as the field value itself, written by an admin UI that only lets editors pick from the finite set. This is really option 1 moved up a layer, into the content model instead of the component, and it's the right shape for anything editorial.

What I wouldn't do: reach for a full safelist of every color times every shade times every variant "just to be safe." I've seen a stylesheet balloon past 400KB that way, most of it dead weight from permutations nobody ever used, because someone got bitten by this once and overcorrected.

Never construct a class name again

The version I'd actually put in a shared design system takes option 1 further and makes the escape hatch structurally hard to reach at all: a cva (class-variance-authority) or similar variant map is the static-mapping pattern from the fix section, formalized as the only way to get a color into that component.

tsx

import { cva } from 'class-variance-authority'

const banner = cva('text-white p-4 rounded', {
  variants: {
    color: {
      red: 'bg-red-600 hover:bg-red-500',
      green: 'bg-green-600 hover:bg-green-500',
      blue: 'bg-blue-600 hover:bg-blue-500',
    },
  },
})

function Banner({ color }: { color: 'red' | 'green' | 'blue' }) {
  return <div className={banner({ color })}>Sale ends Friday</div>
}

Nobody on the team can accidentally write bg-${color}-600 here, because there's no template string in the path at all. TypeScript's union type won't even let you pass a color that isn't in the map. That's the difference between "remembering not to do the broken thing" and "the broken thing not being reachable." I'd take the second one every time it's available, which for component-level color/size/variant props is almost always.

Wiring CI so this fails loudly instead of silently

CSS fails silently by nature, and this bug is a particularly quiet version of that: no console error, no failed build, just a grey box where a red one should be. A few things that turn it into something CI catches before a reviewer has to eyeball a banner:

  • A post-build smoke check that greps the compiled CSS for a short list of classes your design system considers load-bearing (status colors, brand colors), and fails the build if any are missing. Cheap to write, catches exactly this bug.
  • Visual regression snapshots (Playwright, Chromatic, Percy) across your critical components with every documented variant rendered. A missing background color shows up as a pixel diff, not a bug report from marketing.
  • eslint-plugin-tailwindcss's no-custom-classname rule flags class strings the plugin doesn't recognize, which won't catch a template literal directly but will catch typos in the static branches of a variant map, which is where this bug's less obvious cousin lives.
  • If you're mid-migration from v3 to v4, run npx @tailwindcss/upgrade and then diff the compiled output before and after on a representative page. It'll surface content-detection differences (like the node_modules default-ignore change) before production does.
  • Keep the content/@source configuration in one shared file if you're running a monorepo, and add a one-line comment next to it explaining why each entry exists. The entry for the shared UI package is the one someone deletes during a "config cleanup" eighteen months from now, and the banner going grey in production is how they'll find out they shouldn't have.

What to remember

This isn't a rendering bug, it's a build-time visibility problem: Tailwind can only generate CSS for class names it can read as literal text, so anything assembled with +, template interpolation, or a runtime variable simply doesn't exist in the shipped stylesheet, no matter how correct it looks in the DOM. The tell that separates this from an ordinary cascade bug is what the Styles pane shows you: a losing rule looks struck-through, a missing rule looks like nothing at all. node_modules and .gitignored paths are skipped by default in v4, which silently bites monorepos with a shared component package. The real fix is almost always a static lookup map (a cva variant map if you want the compiler to make the mistake unreachable), not a bigger safelist. And I'd verify this is fixed by grepping the actual build output for the class you expect, not by eyeballing the page, because the page can look wrong for a dozen unrelated reasons and the grep tells you the truth in one line.

This one connects to CSS custom-property theming (the bg-(--accent) fix) and, separately, to the "PostCSS plugin has moved" v3-to-v4 migration error: same version bump, a completely different failure, covered on its own.

I've verified this against Tailwind v4.3.3 and the current v3.4 maintenance branch as of writing; if you're on an older v4 minor before the bg-(--accent) parenthesis shorthand landed in 4.1, use the bg-[var(--accent)] bracket form instead, it's been there since v3 and still works identically in v4.

csstailwind-cssvitejavascriptfrontendbuild-toolsReact

Engineer and writer behind CODELZ. I read the stack trace, reproduce the bug, and explain why it happens, not just how to silence it. Plus honest reviews of the books and tools worth your time.

Comments