Tailwind CSS: Why Dynamic Class Names Vanish in Production and How the Safelist Fixes It
Tailwind only emits CSS for class names it can literally see in your source. Here's why template-literal classes vanish in production, and how mapping objects and the safelist fix it.
20 Feb 2026, 03:45 UTC

The class that silently disappears
You build a component whose color comes from data, so you write a template literal like bg-${color}-500. Everything looks fine in development, you ship, and the element renders with no background at all. No build error, no warning — the CSS rule simply does not exist in the production bundle.
This is not a bug. It is the direct consequence of how Tailwind decides what CSS to emit: it scans your source files as plain text and generates only the utility classes it can literally see. Understanding that scan — and its one supported escape hatch — is the difference between a predictable stylesheet and a debugging session.
The scan is the whole engine
Tailwind's compiler reads the files listed in your content configuration (a glob array in tailwind.config.js for v3; automatic content detection with CSS-first setup in v4) and extracts anything that looks like a utility class token. For every match, it emits the corresponding CSS rule. Everything else is ignored. That is why the production stylesheet stays small no matter how large the full utility palette is — you only pay for classes you actually wrote.
The critical detail: detection is static text matching, not code execution. The scanner never runs your JavaScript or evaluates your templates.
class="bg-blue-500"— a complete literal token. Emitted.`bg-${color}-500`— at scan time this is just source text containing a${...}expression. No complete class name exists in the file, so nothing is emitted.w-[137px]— arbitrary values work because the bracket pattern itself is a recognizable token the JIT engine (default since v3) generates on demand.
Prefer mapping objects over interpolation
The standard engineering decision this forces: when you need to translate a business concept into a class, use an object whose values are complete literal strings, so every variant stays scannable.
// Scannable: every value is a complete literal the scanner finds
const toneClasses = {
primary: "bg-blue-600 text-white",
danger: "bg-red-600 text-white",
neutral: "bg-gray-200 text-gray-900"
};
function Badge({ tone, children }) {
return <span className={toneClasses[tone]}>{children}</span>;
}
// Not scannable: the class is assembled at runtime
// className={`bg-${color}-600`} → missing in productionRun this in your project's build (the same command you use for production, e.g. npm run build, with whatever PostCSS or Vite integration your version uses). Then search the emitted CSS file for bg-blue-600 — you should find a rule. Search for a class that only ever existed inside a template literal and you will find nothing. Do not trust the dev server alone for this check; verify against the production output.
The safelist escape hatch
Sometimes classes genuinely cannot exist in source — they come from a CMS, a database, or user-generated content. The supported fix in v3 is the safelist config option, which forces emission regardless of the scan:
// tailwind.config.js (v3 style)
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx,html}"],
safelist: [
"bg-red-600",
{ pattern: /bg-(blue|green|red)-(400|500|600)/ }
]
};Regex patterns keep the safelist bounded — enumerate exactly the variants your data can produce rather than safelisting entire color scales, or you re-inflate the bundle the scan was protecting. In v4, configuration moved to a CSS-first model (@import "tailwindcss" and @theme in a CSS file), and the equivalent mechanisms differ in syntax; check the docs for your installed release before copying any config snippet.
Trade-offs and failure modes
The scan's weakness is that it fails silently. A missing class produces no error — just missing styles — and it can pass review if the component is only ever tested alongside other elements that happen to pull in similar classes. Two recurring causes beyond template literals:
- Files outside the content globs. A shared package in a monorepo, markdown content, or a component library installed from npm will not be scanned unless its path is included. Mysteriously missing styles after extracting a package almost always trace back here.
- Version drift. v3 uses a JS config with a PostCSS plugin; v4 uses CSS-first config and its own Vite plugin or CLI. Confirm which you have with
npm list tailwindcssbefore following any tutorial, because config examples are not interchangeable.
A five-minute verification routine
- Check the installed version and whether your project uses
tailwind.config.js(v3) or a CSS@import "tailwindcss"entry (v4). - Add a static class to a scanned file, build, and grep the output CSS to confirm it was emitted.
- Add a class via template literal that never appears literally, rebuild, and confirm it is absent from the CSS.
- Add that class to the safelist (or your v4 equivalent), rebuild, and confirm it now appears.
- Audit your content globs so every file that can contain class names — including shared packages — is covered.
The habit to build is simple: write classes the scanner can see, map variants through objects instead of string interpolation, and reserve the safelist for data-driven classes you can enumerate explicitly. Your bundle stays small, and no style ever vanishes without a trace.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.