Skip to content

A little — every day.

Migrating Tailwind v3 → v4 in a large monorepo

6 min read

On this page
In short
Tailwind v4 moved configuration from JavaScript to CSS and finds classes in files by itself. In an ordinary project that’s convenient. In a monorepo, where classes live in a dozen libraries outside the app, it’s the number one reason why “styles disappeared” after the upgrade. Below: what changes, four directives you can’t do without (@import, @config, @source, @reference), what to do with Sass, and how to lock in the result with a check in CI.

What changed in v4, in plain words

Tailwind is a CSS generator. It reads your files, finds classes in them (flex, bg-primary, px-4) and writes CSS only for the ones that are actually used.

In v3 this was configured in tailwind.config.js: colours, fonts and the content array — where to look for classes. In v4:

  • Configuration moved to CSS. Colours and fonts are set with @theme right in the CSS file.
  • Class detection is automatic. There’s no content array any more: Tailwind scans the project itself, starting from the folder where the CSS lives.
  • Its own PostCSS plugin. Instead of tailwindcss in PostCSS — the @tailwindcss/postcss package.
  • Autoprefixer is no longer needed — v4 adds prefixes itself.
  • A higher browser baseline: roughly Chrome 111, Safari 16.4, Firefox 128. If your users are on old Safari, check this before you start, not after.

Step 1. PostCSS

Before:

// postcss.config.js
module.exports = {
  plugins: { tailwindcss: {}, autoprefixer: {} },
};

After:

// .postcssrc.json
{ "plugins": { "@tailwindcss/postcss": {} } }

Remove autoprefixer from package.json.

Step 2. Directives in the main CSS

The @tailwind base; @tailwind components; @tailwind utilities; directives don’t exist in v4. Instead — ordinary imports by layer:

@layer theme, base, components, utilities;

@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities" layer(utilities);

A single line, @import "tailwindcss";, works too. We spelled out the layers explicitly because Angular Material styles sit on top of Tailwind, and the layer order decides who overrides whom.

Step 3. The old JS config can stay

You don’t have to rewrite the whole theme in @theme at once. The @config directive plugs in the old JS config or preset:

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

We have a shared preset with colour tokens and a font scale that all apps refer to. Rewriting it in CSS is a separate task. A migration is better done in small steps: first “builds and looks the same”, then improvements.

Dark mode via an attribute, which in v3 was set in the config (darkMode: '[data-mode="dark"]'), is declared in CSS in v4:

@custom-variant dark ([data-mode="dark"] &);

Step 4. @source: the main monorepo trap

Automatic class detection starts at the folder where the CSS file lives and doesn’t reach beyond it the way you need. In a monorepo the shared CSS lives in the UI kit library, while the classes are used in a dozen other libraries and in the apps.

The result: the build passes, there are no errors, and half the pages have no spacing. Tailwind simply didn’t see the files where those classes are written and didn’t generate CSS for them.

The fix is to list explicitly where to look:

@source "./lib/**/*.{ts,html}";
@source "../../shared/**/*.{ts,html}";
@source "../../apps/my-app/src/**/!(*.stories|*.spec).{ts,html}";

A rule for the future: a new app or library that uses this CSS means a new @source line. It’s worth writing a comment about this right in the file, or the next person will get “disappeared styles” again.

Step 5. @reference in component styles

In Angular each component’s styles are compiled separately. If a component’s .scss has @apply:

.card { @apply rounded-xl p-4 bg-background-1; }

then in v4 that file knows nothing about the theme and your tokens — their definitions live in the main CSS. The build fails with “Cannot apply unknown utility class”.

The cure is to reference the main CSS on the first line of the file:

@reference "../../shared-styles.css";

@reference pulls in the definitions but does not duplicate the CSS itself into every component. A plain @import would be a mistake here: all of Tailwind would end up in every component’s styles.

Step 6. Sass and Tailwind v4 don’t get along

Tailwind v4 assumes it is the preprocessor. The combination “Sass with Tailwind directives inside” works badly: Sass tries to parse @source, @reference, @custom-variant before Tailwind does.

What we did:

  • the main stylesheet with Tailwind directives is a plain .css;
  • what really needs Sass (the Angular Material theme built on Sass mixins) went into a separate .scss, included next to it in project.json.

Step 7. Renamed classes

In v4 some classes shifted by one step, for example shadow-sm → shadow-xs, rounded-sm → rounded-xs, outline-none → outline-hidden, and ring now defaults to 1px instead of 3px. The official npx @tailwindcss/upgrade tool renames most of them automatically.

But if your shadows and radii are your own, from the preset, leave their names alone. Check with your eyes rather than trusting the renaming blindly.

A mistake I made

Before editing SCSS files I saved *.scss.bak copies, just in case. They ended up in a commit. A small thing, but:

  • backups are what git is for; separate copies aren’t needed;
  • before committing — git status and a look at the file list, and .bak goes into .gitignore.

How to keep the old ways out

A migration ends not when everything builds, but when old habits can’t come back. After the switch we added a small script to the MR check. It looks only at added lines in .css/.scss and fails the check if:

  • a colour is set by hand (#fff, rgb(...)) instead of a token;
  • CSS newer than the browser baseline (@starting-style, anchor positioning, field-sizing and the like) is used without @supports.

A teaching version of such a check is a dozen lines:

// check-styles.mjs (учебный пример)
import { execFileSync } from 'node:child_process';

const diff = execFileSync('git', ['diff', '-U0', 'origin/main', '--', '*.css', '*.scss'], { encoding: 'utf8' });
const added = diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'));

const bad = added.filter(l => /#[0-9a-f]{3,8}\b|\brgba?\((?!\s*var\()/i.test(l));
if (bad.length) {
  console.error('Цвет задан вручную, используй токен:\n' + bad.join('\n'));
  process.exit(1);
}

Why only added lines: there may be hundreds of old violations, and demanding they all be fixed in every MR means the check will soon be turned off. New ones — not allowed; old ones — we clean up gradually.

Checklist

  1. Check your users’ browser baseline.
  2. PostCSS → @tailwindcss/postcss, remove autoprefixer.
  3. @tailwind ... → @import of layers.
  4. The old config via @config, rewrite later.
  5. @source for every library and app.
  6. @reference in every component stylesheet with @apply.
  7. Tailwind directives in .css, Sass separately.
  8. Walk through the screens with your eyes: desktop and 375px.
  9. A check in CI so the old ways don’t come back.

Related posts: nx-monorepo-from-copies, ci-nx-affected.