Troubleshooting
Symptom-first fixes for real Tailwind v4 problems — missing styles, dead classes, black borders, dark mode that will not toggle, slow builds.
Each section is a symptom, then the cause, then the fix. If nothing at all is styled, start with the first one.
Styles are not applying#
Symptom. No Tailwind class does anything. The page renders with browser defaults, or with only your own hand-written CSS.
Work down this list in order — the cause is almost always in the first three.
- Is the stylesheet reaching the page? Confirm your entry file imports it, or that the built file is linked.
- Is
@import "tailwindcss"in that stylesheet? v4 has one entry point. - Is the right build plugin registered? The package name changed in v4.
- Did the dev server restart? Changes to
postcss.config,vite.configor a newly created CSS file are not hot-reloaded. - Is the markup inside the scanned tree? Files matched by
.gitignoreand everything undernode_modulesare skipped.
import './app.css'@import "tailwindcss";const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default configWith Vite, the plugin has to be in the array, not just installed:
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})A specific class produces no CSS#
Symptom. Most of the page is styled, but one class — often a colour or a size chosen at runtime — does nothing. The class is present in the DOM; there is no matching rule in the stylesheet.
Cause. Tailwind scans source files as plain text. It never executes them, so a class name assembled from fragments does not exist as far as the scanner is concerned.
// ❌ Never generated — the strings "bg-blue-600" and "bg-red-600" appear nowhere
function Button({ color }) {
return <button className={`bg-${color}-600 hover:bg-${color}-500`}>…</button>
}Fix. Map the prop to complete class names.
// ✅ Every class appears literally in the file
const styles = {
blue: 'bg-blue-600 hover:bg-blue-500 text-white',
red: 'bg-red-600 hover:bg-red-500 text-white',
yellow: 'bg-yellow-300 hover:bg-yellow-400 text-black',
}
function Button({ color, children }) {
return <button className={styles[color]}>{children}</button>
}The same rule applies to class="text-{{ error ? 'red' : 'green' }}-600" in a template, to Tailwind class names stored in a database, and to string interpolation in any language.
The file is outside source detection#
Register extra roots with @source:
@import "tailwindcss";
@source "../node_modules/@acmecorp/ui-lib";
@source not "../src/legacy";To take full control, disable automatic detection and list everything explicitly:
@import "tailwindcss" source(none);
@source "../admin";
@source "../shared";The class genuinely cannot appear in source#
Safelist it. @source inline() accepts brace expansion for variants and ranges:
@import "tailwindcss";
@source inline("underline");
@source inline("{hover:,focus:,}underline");
@source inline("{hover:,}bg-red-{50,{100..900..100},950}");Classes work in dev but not in production#
Symptom. The local dev server is correct. The deployed build is missing styles — usually on one component, one route, or content that comes from a CMS.
Causes, most likely first.
| Cause | Fix |
|---|---|
| Class names live in content fetched at runtime, so they were never in any file at build time | @source inline(…) for the finite set, or store semantic names and map them in code |
The production build runs from a different working directory, so relative @source paths resolve elsewhere | Make @source paths relative to the CSS file, and verify with a local production build |
A dependency ships Tailwind classes and is inside node_modules, which is never scanned | @source "../node_modules/@acmecorp/ui-lib"; |
| The deployed CSS is stale — a cached asset or a build that did not rerun | Check the hash on the served file, clear the build cache |
| A second stylesheet overrides Tailwind because it loads later | Order the imports, or move your CSS into @layer components |
Reproduce it locally before changing anything:
npm run build && npx serve distThe editor warns "Unknown at rule @theme"#
Symptom. Yellow squiggles under @theme, @utility, @apply, @source or @custom-variant in VS Code. The build itself is fine.
Cause. The built-in CSS language service validates against plain CSS and has never heard of Tailwind's at-rules.
Fix. Install the Tailwind CSS IntelliSense extension and tell the editor these files are Tailwind, not CSS:
{
"files.associations": {
"*.css": "tailwindcss"
}
}That switches the file to the Tailwind language mode, which understands the directives and enables completion inside them. Do not silence the warning with css.lint.unknownAtRules: "ignore" — that also disables the real diagnostics.
@apply fails inside a Vue or Svelte component style block#
Symptom. Cannot apply unknown utility class — or the theme variables resolve to nothing — inside a single-file component's <style> block, while the same class works in the template.
Cause. Every component style block is compiled as its own stylesheet. It has no knowledge of your theme, custom utilities or custom variants.
Fix. Import the main stylesheet for reference with @reference. It makes the definitions available without duplicating any CSS in the output.
<template>
<h1>Hello world</h1>
</template>
<style>
@reference "../../app.css";
h1 {
@apply text-2xl font-bold text-red-500;
}
</style>If you have not customised the theme, reference the default directly:
<style>
@reference "tailwindcss";
h1 { @apply text-2xl font-bold; }
</style>A v3 snippet does not work#
Symptom. Code copied from a blog post, a component library or an older answer produces errors or nothing at all.
Cause. v4 changed the entry point, the configuration format and a number of class names.
| v3 | v4 |
|---|---|
@tailwind base/components/utilities | @import "tailwindcss" |
tailwind.config.js read automatically | @theme in CSS, or opt in with @config |
theme.extend.colors in JS | --color-* in @theme |
bg-opacity-50 | bg-black/50 |
bg-gradient-to-r | bg-linear-to-r |
flex-shrink-0 / flex-grow | shrink-0 / grow |
outline-none | outline-hidden |
The complete list is on Deprecated APIs, and the upgrade path is on v3 to v4 Migration. If you need a JavaScript config to keep working during a migration:
@import "tailwindcss";
@config "../../tailwind.config.js";border renders black#
Symptom. class="border" draws a black line where v3 drew a light grey one.
Cause. In v4 the default border colour is currentColor rather than gray-200, so a border inherits the element's text colour.
Fix. State the colour. This is the recommended long-term answer — it makes the border visible in the class list.
<div class="border border-gray-200">…</div>To restore the v3 behaviour across a legacy codebase:
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}The same change applies to divide-*, which also defaults to currentColor.
ring looks too thin#
Symptom. A focus ring that used to be a clear 3px blue outline is now a 1px hairline in the text colour.
Cause. In v3, ring meant 3px blue-500. In v4 it means 1px currentColor.
Fix. Ask for what you want.
<!-- v3 behaviour -->
<button class="ring-3 ring-blue-500">…</button>
<!-- Usually better: an outline, which does not join the shadow chain -->
<button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">…</button>Shadows look too light#
Symptom. Cards sit flatter than they did before the upgrade.
Cause. The shadow scale gained a step at the small end and every name shifted down one.
| v3 | v4 |
|---|---|
shadow-sm | shadow-xs |
shadow | shadow-sm |
shadow-md and up | unchanged |
So a v3 shadow-sm still compiles in v4 — it just renders one step lighter than it used to.
Fix. Shift the small end up one step: shadow-sm becomes shadow-md, a bare shadow becomes shadow-sm. The same shift applies to drop-shadow-*, blur-* and rounded-*.
sticky does not stick#
Symptom. sticky top-0 scrolls away like a static element.
Causes.
- No offset.
position: stickydoes nothing without at least one oftop,right,bottomorleft.sticky top-0— never baresticky. - An ancestor has
overflowset. Any ancestor withoverflow-hidden,overflow-auto,overflow-scrolloroverflow-clipbecomes the scroll container, and the element sticks inside that box — which is often exactly as tall as its content, so nothing moves. - The parent is not taller than the element. Sticky positioning only has room to travel within its direct parent.
Fix. Find the offending ancestor and drop the overflow utility, or make it the intended scroll container.
<!-- ❌ overflow-hidden on the wrapper kills the sticky header -->
<div class="overflow-hidden">
<header class="sticky top-0">…</header>
</div>
<!-- ✅ clip only the axis that needs clipping -->
<div class="overflow-x-clip">
<header class="sticky top-0">…</header>
</div>overflow-x-hidden still creates a scroll container on the other axis, which is why the -clip variants are usually the better choice.
z-index has no effect#
Symptom. z-50 sits behind an element with a lower z-index, or behind one with none at all.
Causes.
- The element is not positioned.
z-*applies to positioned elements and to flex and grid items. Addrelative. - An ancestor created a stacking context. A child can never escape its parent's stacking context, so
z-50inside a context stacked atz-10still loses to a sibling of that context atz-20.
Anything in this list creates a stacking context: position with a z-index, transform, filter, backdrop-filter, perspective, opacity below 1, will-change, isolate, contain: paint, and being a flex or grid item with z-index.
Fix. Raise the ancestor, not the child.
<!-- ❌ z-50 is trapped inside the transformed card -->
<div class="transform">
<div class="z-50">…</div>
</div>
<!-- ✅ lift the whole context -->
<div class="relative z-50 transform">
<div>…</div>
</div>Use isolate deliberately to fence a component's internal layers so they cannot leak into the page:
<div class="isolate">…internal z-10, z-20 stay contained…</div>truncate does nothing#
Symptom. Text inside a flex or grid item overflows or pushes its siblings out instead of ending in an ellipsis.
Cause. A flex item's min-width defaults to auto, which means "never shrink below my content". overflow-hidden cannot take effect on a box that refuses to be narrower than its text.
Fix. Add min-w-0 to the flex item, and to every flex item between it and the container.
<div class="flex items-center gap-3">
<img class="size-10 shrink-0 rounded-full" src="/a.jpg" alt="" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium">A very long display name that has to be cut off</p>
<p class="truncate text-sm text-gray-500">and@an-even-longer-email-address.example.com</p>
</div>
</div>In grid, the equivalent is min-w-0 on the item or grid-cols-[minmax(0,1fr)] on the container. For vertical clipping the same trick uses min-h-0.
A fixed element is not fixed to the viewport#
Symptom. A modal, drawer or dropdown with fixed inset-0 is positioned relative to a parent instead of the window, and scrolls with the page.
Cause. transform, filter, backdrop-filter, perspective, contain: paint or will-change: transform on any ancestor makes that ancestor the containing block for fixed descendants. This is spec behaviour, not a bug.
The usual culprits are subtle: a card with hover:scale-105, a nav with backdrop-blur-md, or a wrapper with will-change-transform left over from an animation.
Fix. Render the fixed element outside the transformed subtree — a portal to document.body in React, <Teleport to="body"> in Vue — or remove the transform from the ancestor.
createPortal(
<div className="fixed inset-0 z-50 bg-black/50">…</div>,
document.body,
)Dark mode does not toggle#
Symptom. Adding or removing a dark class on <html> changes nothing. dark: styles only appear when the operating system is set to dark.
Cause. The default dark variant is @media (prefers-color-scheme: dark). It reads the OS, and no class can influence a media query.
Fix. Redefine the variant so it reads a class you control:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));Or a data attribute, which avoids colliding with a class-name utility:
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));The :where() wrapper keeps specificity at zero, so dark: utilities still lose to more specific overrides in the normal way. Full detail is on Dark Mode.
The page flashes the wrong theme on load#
Symptom. A white flash before the dark theme applies, or the wrong theme for one frame on every navigation.
Cause. The stored preference is read after hydration — in a useEffect, an onMounted, or a deferred script — which is at least one paint too late.
Fix. Set the class in a blocking inline script in <head>, before any content renders.
<head>
<script>
document.documentElement.classList.toggle(
'dark',
localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches),
)
</script>
</head>Keep it inline and synchronous — an external or defered script reintroduces the flash. Also declare the scheme so native scrollbars, form controls and the canvas behind your page match:
<meta name="color-scheme" content="light dark" />The build is slow#
Symptom. Rebuilds take seconds instead of milliseconds.
Causes and fixes.
| Cause | Fix |
|---|---|
An @source glob that reaches into node_modules or a monorepo root | Narrow it, or switch to source(none) plus explicit @source entries |
| Build output is being scanned, so every generated class is fed back in | @source not "../dist"; — and keep build output in .gitignore |
postcss-import and autoprefixer still in the pipeline | Remove both; v4 does inlining and prefixing internally |
| Using PostCSS where Vite is available | Switch to @tailwindcss/vite, which skips the PostCSS round trip |
| Heavy safelisting with brace expansion | Enumerate only the classes you actually need |
The CSS bundle is larger than expected#
Symptom. The compiled stylesheet is hundreds of kilobytes.
Cause. Tailwind emits exactly the classes it finds. A large bundle means it is finding more than you think.
Check, in order:
- Is build output being scanned? A compiled
dist/or.next/inside the source tree makes the scanner rediscover every class the previous build emitted. Exclude it with@source not. - How much is safelisted?
@source inline()with brace expansion multiplies fast. - Is
@theme staticin use? It emits every variable in the block, used or not. Use it only for tokens JavaScript reads. - Are several entry stylesheets each importing
tailwindcss? Each one produces a complete copy of preflight. - Is a JS config still loaded via
@configwith a broadcontentarray? Its globs still apply.
Measure before optimising:
npx @tailwindcss/cli -i ./src/app.css -o ./out.css --minify
wc -c out.cssMore on shipping builds is on Production Builds.
IntelliSense is not completing class names#
Symptom. No autocomplete, no colour swatches, no hover previews.
Causes and fixes.
- The extension predates v4. v4 support requires a recent Tailwind CSS IntelliSense release — update it.
- No CSS entry point was found. In v4 the extension looks for a stylesheet containing
@import "tailwindcss", not fortailwind.config.js. A project with no such file, or one hidden behind an unusual layout, gives the extension nothing to read. - The file type is not registered. Add
"files.associations": { "*.css": "tailwindcss" }, and extendtailwindCSS.includeLanguagesfor template languages the extension does not recognise. - The language server has stale state. Run Developer: Reload Window after changing
@themeor adding a stylesheet. - A monorepo with several stylesheets. Point the extension at the right entry with the
tailwindCSS.experimental.configFilesetting.
Debugging checklist#
When something is wrong and you do not yet know what:
- Is the class in the DOM? Inspect the element. If the class is missing, it is a template problem, not a Tailwind problem.
- Is the rule in the stylesheet? Search the served CSS for the class name. If it is absent, Tailwind never saw the class — go to A specific class produces no CSS.
- Is the rule being overridden? The browser's Styles pane shows the winning declaration and strikes through the losers.
- Is the element even affected by the property?
z-indexon a static element andgapon a block container both do nothing. - Does it work on a bare element? Drop the same class on a plain
<div>at the top of the page. If it works there, an ancestor is the cause.
Inspect the generated CSS with the CLI#
The fastest way to answer "did Tailwind generate this class?" is to build once and search the output. This works in any project, whatever bundler it normally uses.
npx @tailwindcss/cli -i ./src/app.css -o ./out.cssgrep -n "bg-brand-500" out.cssNo match means the class was never detected — a source-detection or dynamic-class-name problem. A match means the CSS exists and the issue is specificity, load order, or the markup.
To watch the output change as you edit:
npx @tailwindcss/cli -i ./src/app.css -o ./out.css --watchThe same file also shows which theme variables were emitted, which is the quickest way to confirm an @theme block took effect.
Common mistakes#
- Registering
tailwindcssas the PostCSS plugin. It is@tailwindcss/postcssin v4. - Leaving the v3
@tailwinddirectives in the stylesheet. Replace all three with@import "tailwindcss". - Building class names by concatenation. The scanner reads text, not code.
- Reaching for
!importantwhen a style loses. Nine times out of ten it is a stacking context, amin-width: auto, or an overflow ancestor — fix the cause. - Safelisting to work around dynamic classes. It hides the problem and inflates the bundle.
- Not restarting the dev server after editing
postcss.config,vite.configor creating a new stylesheet.
Related topics#
- Installation — the correct plugin for each build tool
- Deprecated APIs — every renamed and removed v3 class
- Production Builds — shipping, caching and bundle size
- Overflow & Layering — stacking contexts and scroll containers in depth
- Cheat Sheet — a fast lookup once the build works