Tailwind v4 Migration - CSS-First Config, @theme, and Common Breakages

Status: Active
Last Updated: 2026-08-26
Category: Frontend - Styling
Prerequisites: website-rebuild, component-library-notes
Time: 2 hours
Tags: tailwindcss, tailwind-v4, css-first-config, migration, postcss

Summary

What changed between Tailwind CSS v3 and v4, how the fogserv.cloud website straddles the line today (v4 PostCSS plugin installed, but a legacy tailwind.config.ts still present), a step-by-step migration to pure CSS-first configuration with @theme, and the breakages you'll hit along the way.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Context / Why This Matters

The website (website-rebuild) was built on Tailwind v4 โ€” @tailwindcss/postcss is in postcss.config.js โ€” but the repo still carries a v3-era tailwind.config.ts mapping brand colors (primary, secondary, accent tan/terracotta) to CSS variables. That hybrid works only because v4 auto-detects config files; it's deprecated behavior, and it confuses contributors about where tokens live. Finishing the migration makes the design system single-sourced in CSS.

Implementation / Core Content

What Changed in v4

v3 v4
tailwind.config.js/ts required for content + theme Optional; content detection automatic, theme lives in CSS
@tailwind base; @tailwind components; @tailwind utilities; Single @import "tailwindcss";
Theme extensions in JS objects @theme { --color-primary: ... } in CSS
Plugins loaded via config array @plugin "@tailwindcss/typography"; in CSS
PostCSS: tailwindcss plugin PostCSS: @tailwindcss/postcss package
dark: = media strategy by default Same, but @custom-variant dark to opt into class strategy
Opacity modifier math Uses native color-mix()

Current State Audit (this repo)

// postcss.config.js โ€” correct for v4
export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}
// tailwind.config.ts โ€” legacy v3-style, to be retired after token migration
export default {
  content: ['./src/**/*.{ts,tsx}'],
  theme: { extend: { colors: {
    primary: { DEFAULT: 'var(--color-primary)', hover: 'var(--color-primary-hover)', ... },
    ...

Note the indirection: config points at CSS variables. In v4's @theme, those variables are the theme, so one layer disappears.

Step-by-Step Migration

  1. Replace directives in src/index.css:

    /* before */
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
    /* after */
    @import 'tailwindcss';
    
  2. Move tokens into @theme, translating the config's variable references into direct definitions (keep the same variable names so existing usage keeps working):

    @theme {
      --color-primary: #2f6f5e;            /* actual brand value */
      --color-primary-hover: color-mix(in oklab, var(--color-primary) 85%, black);
      --color-primary-light: color-mix(in oklab, var(--color-primary) 80%, white);
      --color-secondary: ...;
      --color-accent-tan: ...;
      --color-accent-terracotta: ...;
      --font-display: ..., sans-serif;
    }
    

    Utilities like bg-primary, text-accent-tan-hover are generated from these names automatically.

  3. Load plugins in CSS, not config:

    @plugin '@tailwindcss/typography';
    

    (@tailwindcss/typography is already a dependency.)

  4. Delete tailwind.config.ts once nothing references it, then remove any leftover autoprefixer entry โ€” v4 handles vendor prefixing internally.

  5. Verify visual parity: build and diff-render key pages (/, /kb/, /admin) against screenshots before merging.

If any v3-only syntax survives (e.g., class-strategy dark mode), declare it explicitly:

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

Why Migrate at All?

Practical Examples

Example 1: Before/after for one brand token

// v3 (tailwind.config.ts)
primary: { DEFAULT: 'var(--color-primary)', hover: 'var(--color-primary-hover)' }
/* v4 (@theme in index.css) */
@theme {
  --color-primary: var(--brand-primary);        /* keep runtime theming if desired */
  --color-primary-hover: var(--brand-primary-hover);
}

Class names (bg-primary, hover:bg-primary-hover) are unchanged โ€” components need no edits.

Example 2: Verifying migration completeness

grep -rn "tailwind.config" src/ vite.config.ts app.config.ts   # expect no hits
bun run build && bun run preview                                # visual smoke test
grep -c "@theme" src/index.css                                  # โ‰ฅ 1

Example 3: Typography plugin check

Any page rendering markdown via react-markdown should use prose prose-primary; after migrating the plugin load, confirm headings/code blocks still style correctly โ€” a missing @plugin line shows as unstyled article HTML.

Common Pitfalls & Troubleshooting

Problem Cause Fix
All custom colors render as plain CSS vars / transparent Tokens still only in legacy config; import order wrong Move tokens into @theme; ensure @import "tailwindcss" precedes @theme
prose classes do nothing Typography plugin not loaded under v4 Add @plugin '@tailwindcss/typography'; to index.css
npx tailwindcss init errors or warns v4 has no CLI-init workflow Don't re-create a config; use @theme
Dark mode stops responding to .dark class v4 default is media strategy Declare @custom-variant dark (&:where(.dark, .dark *));
Build fails: "Cannot apply unknown utility class" @apply referencing classes defined only in old config Define token in @theme first, or replace @apply with plain CSS vars
Content not scanned (styles missing on some pages) Old mental model: manual content globs needed v4 scans automatically; check the file isn't gitignored instead
Duplicate autoprefixer warnings Leftover postcss plugin Remove autoprefixer from postcss config and devDependencies

Next Steps / Ops Actions

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse