Skip to content

Fix: Tailwind v4 Not Working — @theme, CSS-First Config, PostCSS vs Vite, and v3 Migration

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Tailwind CSS v4 errors — tailwind.config.js ignored, @import 'tailwindcss' not loading, @theme custom values not applied, content scanning misses files, Vite plugin setup, and v3 to v4 migration gotchas.

The Error

You upgrade to Tailwind v4 and classes stop applying:

<div class="bg-blue-500 p-4">Hello</div>
<!-- No styling. -->

Or your tailwind.config.js is completely ignored:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: { brand: "#ff0080" },
    },
  },
};
<div class="text-brand">Hello</div>
<!-- Still no color. -->

Or the new @theme directive doesn’t work:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.7 0.2 30);
}

Or your PostCSS config errors out:

Error: It looks like you're trying to use `tailwindcss` directly as a PostCSS plugin.
The PostCSS plugin has moved to a separate package.

Why This Happens

Tailwind v4 is a ground-up rewrite. Three structural changes that break v3 setups:

  • CSS-first config. tailwind.config.js is no longer the source of truth. Theme tokens live in CSS via @theme { ... }. The JS config still works for migration but is opt-in.
  • PostCSS plugin moved. tailwindcss itself is no longer the PostCSS plugin. The plugin is now @tailwindcss/postcss. For Vite users, there’s a dedicated @tailwindcss/vite plugin that’s faster and recommended.
  • @import replaces @tailwind directives. @tailwind base; @tailwind components; @tailwind utilities; becomes a single @import "tailwindcss";.

The “classes don’t apply” issue is usually one of: the PostCSS plugin isn’t wired up, the new import directive is missing, or the content scanner doesn’t see your template files. The two other failure modes that bite hard are PostCSS plugin ordering (running @tailwindcss/postcss after autoprefixer or postcss-import instead of before) and content scanning silently missing files that live outside the project root, like a shared design-system package in a sibling directory.

Production Incident Lens: When a Tailwind Upgrade Breaks Production CSS

The Tailwind v4 production incident has a specific shape: the upgrade goes through CI green, the staging smoke test passes, you ship to production, and within 30 seconds users start reporting “the site looks broken.” The home page renders without spacing, the brand color is gone, dark mode is inverted, modals overflow the viewport. The blast radius is every page on the site at once, because CSS is global. Unlike a backend incident where one endpoint can degrade in isolation, a CSS regression is total — the entire visual surface goes down together.

Why does this slip past CI? Three reasons. First, snapshot tests usually run against unstyled DOM, so they pass even when styling is wrong. Second, visual regression tools (Percy, Chromatic) often get skipped on CSS-only PRs because reviewers assume “it’s just Tailwind, classes don’t change.” Third, the v3-to-v4 migration tool handles 90% of cases automatically — but the 10% it misses are exactly the custom plugins and safelist entries that hold the design system together.

When the page fires, your fastest rollback is at the edge: serve the previous build’s CSS bundle from your CDN while you diagnose. If you’re on Cloudflare Pages, Vercel, or Netlify, the previous deployment is one click away. Don’t try to hot-fix Tailwind during the incident — too many moving parts. Roll back, then reproduce locally with pnpm build && pnpm preview, diff the generated CSS file against the previous green build, and look for missing utility classes. A single missing bg-brand in the output usually traces to a @theme token that didn’t get re-declared after the upgrade tool flattened the JS config.

The right monitoring signal is CSS bundle size delta per deploy. A v4 upgrade that drops the bundle from 80KB to 12KB usually means content detection failed and most utility classes got tree-shaken out as unused. Wire this into your CI as a budget check: fail the build if the CSS bundle shrinks by more than 30% deploy-to-deploy without a corresponding code change. It catches the worst class of “looks broken on production” before users see it.

Fix 1: Update the PostCSS or Vite Plugin

For Vite projects (Astro, SvelteKit, React with Vite, etc.) — switch to the Vite plugin:

npm install -D tailwindcss @tailwindcss/vite
// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});

Remove the old PostCSS config if it only existed for Tailwind. The Vite plugin runs Tailwind directly — no PostCSS round-trip.

For Next.js, Remix, or other PostCSS-driven setups:

npm install -D tailwindcss @tailwindcss/postcss
// postcss.config.mjs (or postcss.config.js)
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

Remove tailwindcss from your PostCSS plugins — that’s v3. The plugin moved.

Pro Tip: The Vite plugin is dramatically faster than PostCSS for Tailwind. If your bundler supports it, prefer it.

Fix 2: Use @import "tailwindcss" Once

In your single global CSS file:

@import "tailwindcss";

That’s it. No @tailwind base, @tailwind components, @tailwind utilities. The single import pulls in all of Tailwind.

For more control, you can import sub-layers individually:

@import "tailwindcss/preflight";
@import "tailwindcss/utilities";
/* Skipping the components layer */

But the recommended pattern is the single import.

Common Mistake: Mixing v3 and v4 directives:

/* WRONG: */
@import "tailwindcss";
@tailwind utilities;

/* Correct: */
@import "tailwindcss";

If you copy-pasted from a v3 tutorial, find and replace the old directives.

Fix 3: Define Theme Tokens With @theme

Custom colors, spacing, fonts — everything that used to live in theme.extend — goes in a @theme block:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.7 0.2 30);
  --color-brand-soft: oklch(0.85 0.1 30);
  --font-display: "Geist", "system-ui", sans-serif;
  --spacing-128: 32rem;
  --breakpoint-3xl: 1920px;
}

These become available as utility classes:

  • --color-brandbg-brand, text-brand, border-brand, etc.
  • --font-displayfont-display
  • --spacing-128p-128, m-128, w-128
  • --breakpoint-3xl3xl: prefix

The naming convention (--color-*, --font-*, --spacing-*, --breakpoint-*) is how Tailwind knows what category each variable belongs to.

Note: Theme variables become real CSS custom properties on :root. You can read them in any CSS too: color: var(--color-brand).

Fix 4: Use the JS Config Compatibility Mode (Migration Path)

To keep your tailwind.config.js working during migration, opt in explicitly:

@import "tailwindcss";
@config "../../tailwind.config.js";

The @config directive tells Tailwind to load and merge a v3-style config. This is the bridge while you migrate theme.extend entries into @theme.

Once you’ve moved everything to CSS, delete the config file and remove the @config line.

Common Mistake: Setting up @config and @theme for the same token. The CSS @theme wins. If your JS color isn’t appearing, check whether it’s also defined in @theme.

Fix 5: Content Detection Is Automatic But Has Limits

Tailwind v4 auto-detects template files in your project — no content array needed in most cases. It scans:

  • Files in your project (excluding node_modules and .gitignored paths by default).
  • Common template extensions: .html, .js, .jsx, .ts, .tsx, .vue, .svelte, .astro, etc.

If classes from a particular file aren’t appearing:

@import "tailwindcss";

@source "../node_modules/some-ui-library/dist/**/*.js";

@source tells Tailwind to scan extra paths. Use it when:

  • A third-party UI library ships pre-built JS with Tailwind classes you want to keep.
  • You generate templates outside the default scanned paths.

To explicitly exclude a path:

@source not "./src/legacy/**";

Pro Tip: If you migrated from v3 and had a long content: [...] array, replace each entry with @source "...". The behavior is similar.

Fix 6: Dark Mode Configuration

v4 default is prefers-color-scheme. To use a class-based toggle:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

Now toggling <html class="dark"> switches the theme. This pattern is more flexible than v3’s darkMode: 'class' — you can use any selector, like a data-theme="dark" attribute:

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

The @custom-variant directive replaces v3’s variant config. Use it for any selector-based variant you need.

Fix 7: Plugins and @plugin

Third-party Tailwind plugins still work but are loaded differently:

@import "tailwindcss";

@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "./my-local-plugin.js";

No more plugins: [require('@tailwindcss/typography')] in JS config (unless you’re using @config compatibility).

For first-party plugins, install and reference:

npm install -D @tailwindcss/typography @tailwindcss/forms

Custom plugins still use plugin() from tailwindcss/plugin:

// my-local-plugin.js
import plugin from "tailwindcss/plugin";

export default plugin(({ addUtilities }) => {
  addUtilities({
    ".text-shadow": { textShadow: "0 1px 2px rgba(0,0,0,0.1)" },
  });
});

Fix 8: Migrate From v3 With the Upgrade Tool

The official upgrade tool handles most v3 → v4 conversions:

npx @tailwindcss/upgrade

It:

  • Replaces @tailwind directives with @import "tailwindcss".
  • Converts simple tailwind.config.js themes into @theme blocks in your CSS.
  • Updates PostCSS config to use @tailwindcss/postcss.
  • Removes deprecated utility classes (flex-grow-0grow-0, etc.).
  • Adds @config for complex configs it couldn’t auto-migrate.

Run it on a clean git tree and review the diff carefully. Known cases the tool can’t auto-fix:

  • Custom plugins that touch internal APIs (the plugin API was simplified — check each plugin).
  • Theme tokens that depended on JS functions (e.g. dynamically generated colors).
  • safelist entries — review and convert to explicit class usage or @source includes.

Common Mistake: Running the upgrade on a project with a half-migrated state. Either commit your v3 state first and run the tool, or finish manual migration before running it.

Still Not Working?

A few less-obvious failures:

  • oklch() color not rendering on old browsers. v4 uses modern color spaces by default. Browsers older than ~2023 don’t support oklch. Polyfill with @supports or use the fallback the Tailwind upgrade tool provides.
  • Build size is huge after upgrade. v4 ships more by default. Inspect the generated CSS or use the Tailwind CLI’s verbose flag (check tailwindcss --help for your version) to see which files are scanned. Use @source not "..." to exclude generated/test files.
  • Arbitrary values like text-[18px] not working. Should still work — confirm the import directive is correct and the file is being scanned. Check for typos in the bracket syntax.
  • @layer base { ... } styles not applying. v4 still supports @layer, but the layer cascade order changed. @layer base styles come before component utilities; ensure you’re not accidentally overriding them later.
  • TypeScript types missing for tailwindcss/plugin. Install @types/tailwindcss if your editor complains, though v4 ships its own types in most setups.
  • Hot reload not picking up CSS changes. The Vite plugin should handle this. For PostCSS, ensure @tailwindcss/postcss is fresh enough to support HMR.
  • @apply slower than v3. v4 changed @apply internals. For hot paths, consider inlining the utility classes directly rather than wrapping in @apply.
  • Astro/SvelteKit project: classes work in .astro files but not in JS-generated components. Add the JS path with @source if it’s outside the auto-scan defaults.

CSS Bundle Shrinks Drastically After Upgrade

If the generated CSS file is 10x smaller than v3 produced, content detection isn’t picking up your templates. Run the build with verbose logging and check the list of scanned files. Most often the culprit is a workspace package (under packages/ui in a monorepo) that v3 scanned because of an explicit content entry but v4 ignores because it’s outside the default project boundary. Add @source "../packages/ui/src/**/*.{ts,tsx,vue,svelte,astro}"; to the entry CSS file.

Dark Mode Inverts On First Paint Then Snaps to Correct Mode

You’re seeing a FOUC because the @custom-variant dark directive resolves after the HTML loads. Set the class="dark" (or data-theme="dark") on <html> from an inline script at the top of <head> before any CSS loads — read the stored preference from localStorage and apply it synchronously. This is unchanged from v3 but easy to forget when redoing the dark-mode wiring during the v4 migration.

Monorepo Package Ships Its Own Tailwind and Conflicts

A shared UI package built with its own Tailwind copy injects styles at runtime that fight the consumer app’s styles. The v4-correct pattern is to ship the UI package as uncompiled templates (the consumer’s Tailwind scans them) or as already-resolved CSS that consumers import once. Don’t ship both. The hybrid path explodes specificity and leaves you debugging which bg-blue-500 wins.

For related CSS and Tailwind issues, see Tailwind classes not applying, CSS tailwind not applying, Vite failed to resolve import, and CSS variable not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles