Migrated Tailwind 3.4.19 to 4.3.3 on Astro 7.1.4, on 2026-09-03.
npx @tailwindcss/upgrade did more than we expected and less than enough. On a real Astro site, one command rewrote the PostCSS config, translated the JavaScript config into CSS, deleted tailwind.config.mjs, and renamed classes across 31 files, every change correct. Then the build failed twice, on two errors that are specific to how Astro bundles component styles and that the tool could have prevented. And the main stylesheet came out 28% larger gzipped than it was on v3.
This is the field report to sit next to our Tailwind v4 overview, which covers the model and the trade-offs in general. This one is a single migration, measured end to end, with the diffs.
The site being migrated
The site is selim.services, which is one of our own (see the disclosure at the end). It is a good migration subject because nothing about its setup is exotic:
- Astro 7.1.4,
tailwindcss3.4.19. - A hand-written
postcss.config.cjswithtailwindcssandautoprefixer, rather than the@astrojs/tailwindintegration. global.csswith the three@tailwind base/components/utilitiesdirectives and a font@importabove them.- A 17-line
tailwind.config.mjs:darkMode: "class", acontentarray,future: { hoverOnlyWhenSupported: true }, and atheme.containerblock withcenter: trueandpadding: "1rem". No custom colours in the config. - 23
@applydirectives: most inglobal.css, and a handful inside<style>blocks in two.astrocomponents.
Those two component <style> blocks are where this stopped being a one-command job.
What one command did, and got right
npx @tailwindcss/upgrade needs Node 20 or newer.1 It ran on Node 24 and exited clean. In one pass it made every one of these changes:
-
Dependencies.
tailwindcss3.4.19 to 4.3.3, installed@tailwindcss/postcss, and removedautoprefixer. v4 does vendor prefixing internally through Lightning CSS, soautoprefixeris dead weight.2 -
PostCSS config.
{ tailwindcss, autoprefixer }became{ '@tailwindcss/postcss' }. -
The config file.
tailwind.config.mjsdeleted entirely. Seventeen lines to zero. -
global.css. The three@tailwinddirectives became a single@import 'tailwindcss'; the font@importgained alayer(base); and it appended a compatibility block that setsborder-colorback to the v3 default ofgray-200, because v4's default iscurrentColor. -
Dark mode.
darkMode: "class"became@custom-variant dark (&:is(.dark *))in the CSS. The upgrade guide does not even mentiondarkMode, so this one was a bonus. -
The container utility.
theme.containerbecame an@utilityrule. The upgrade guide says thecenterandpaddingoptions "no longer exist" in v4.1 The tool did not just drop them; it re-expressed them, keeping the real values:
css@utility container { margin-inline: auto; padding-inline: 1rem; } -
A
futureflag.future: { hoverOnlyWhenSupported: true }was dropped, because that behaviour is the default in v4. -
Class renames. Across 31 template files. Every distinct rename we checked was correct against the v4 scale:
v3 v4 outline-noneoutline-hiddenrounded-smrounded-xsbackdrop-blur-smbackdrop-blur-xsshadowshadow-smleading-[1.5]leading-normalleading-[1.25]leading-tighttracking-[0.1em]tracking-widest-mx-[140px]mx-[-140px]aspect-[16/10]aspect-16/10bg-white/[0.02]bg-white/2[overflow-wrap:anywhere]wrap-anywhereWhere no v4 token matched, for example
tracking-[0.12em], it correctly left the arbitrary value alone.
If the site had used a JavaScript config only for content and a couple of flags, this would have been the whole job. It did not stay that simple.
Error 1: @apply in an Astro style block
The first astro build after the upgrade failed:
Cannot apply unknown utility class `md:mt-20`. Are you using CSS modules or
similar and missing `@reference`?
Location: src/components/blog/Sources.astro:88:1
Sources.astro has a <style> block that uses @apply with utility classes. The upgrade tool had gone into that block and renamed a class inside it ([overflow-wrap:anywhere] to wrap-anywhere), so it clearly parsed the block. It did not add the one line that makes @apply work there in v4.
The cause is documented, in prose, in the upgrade guide: "stylesheets that are bundled separately from your main CSS file (e.g. CSS modules files, <style> blocks in Vue, Svelte, or Astro, etc.) do not have access to theme variables, custom utilities, and custom variants defined in other files."1 The fix is the @reference directive, which imports your main stylesheet for resolution without duplicating any output:3
@reference "../../styles/global.css";cssadded as the first line of each affected <style> block. Two components needed it.
The tool knows exactly which files this applies to, because it just edited them. It renames the classes and leaves the directive for the build to demand.
Error 2: @reference and the PostCSS plugin
With the @reference lines in, the build failed differently:
[postcss] ENOENT: no such file or directory, open '<project>/tailwindcss'
@reference "../../styles/global.css" pulls that file in for resolution. global.css contains @import 'tailwindcss'. Astro's bundled postcss-import sees the bare specifier and tries to open ./tailwindcss as a file. The @tailwindcss/postcss plugin the upgrade tool just configured never gets to handle it. This is a known friction point for v4 on Astro.4
The fix is the one the upgrade guide recommends for Vite users anyway, and Astro is a Vite project: move off the PostCSS plugin to @tailwindcss/vite.2
npm install -D @tailwindcss/vite
rm postcss.config.cjsbash// astro.config.mjs
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
vite: { plugins: [tailwindcss()] },
// ...rest unchanged
});jsautoprefixer was already gone, so nothing else needed PostCSS and the config file could be deleted outright. After this, astro build completed and produced all 192 pages.
The upgrade tool does not make this change. It migrates whatever PostCSS setup it finds to the new PostCSS plugin and leaves you there, which on Astro is a setup that breaks the moment @reference enters the picture.
The dark variant works, with a caveat
The line the tool generated:
@custom-variant dark (&:is(.dark *));css&:is(.dark *) matches an element that is a descendant of something carrying class="dark". This site toggles dark mode by putting that class on <html>. <html> is not a descendant of itself, so a dark: utility placed directly on <html> would not take. :is() also carries the specificity of its most specific argument, where :where() contributes none.
For this site nothing on <html> needed a dark: utility, so the generated line is fine as written. But if you toggle by class on the root element, the form our overview recommends is safer:
@custom-variant dark (&:where(.dark, .dark *));cssIt matches the toggled element itself as well as its descendants, and keeps specificity flat.5 Worth checking which one you were handed.
The CSS got bigger
We built both versions with astro build, with the stylesheet emitted as a file rather than inlined, and measured the main sheet:
| raw | gzip -9 | |
|---|---|---|
| v3 | 38,430 B | 8,473 B |
| v4 | 57,568 B | 10,825 B |
| change | +19,138 B (+50%) | +2,352 B (+28%) |
v4 exposes every design token as a CSS custom property on :root by default: "Tailwind CSS v4.0 takes all of your design tokens and makes them available as CSS variables by default."2 Every default colour, every spacing step, every font family and easing curve is written into the output whether a utility references it or not, and @property rules are emitted for animatable utilities. On a site that uses a narrow slice of the default palette, that is a fixed cost with nothing on the other side of it. It is recoverable, by disabling colour families in @theme, but the out-of-the-box number goes up.
Our overview measured this blog's entire v4 stylesheet at 13.3 KB gzipped, which is small. Both statements hold: v4 output is small in absolute terms and larger than the v3 output for the same source. If the reason for upgrading is bundle size, measure before you commit to it.
Where this is weak
- One site, and a small one. Around 200 pages, a modest config, one team's conventions. A large application with a heavily customised theme and many plugins will surface migration issues this site cannot, and its bundle arithmetic will differ.
- The config was already simple.
darkMode,content, onefutureflag, acontainerblock, and no custom colours intailwind.config. Sites with a largetheme.extendare where the CSS-first translation is worth watching, and this migration does not exercise that path. - Build verified, not rendered. The v4 build produces every page and every class rename checks out, but this is not a full visual regression. The border and ring default changes that the compatibility block papers over can still look slightly wrong somewhere until screenshots are compared.
@tailwindcss/vitewas the fix here; it is not always on the table. An older Astro, or a non-Vite bundler, would need a different way out of the@referenceand PostCSS collision.- Version-specific.
@tailwindcss/upgradeat 4.3.3, Astro 7.1.4. Both move fast, and a later upgrade tool may close one or both of these gaps.
Doing it on your own site
- Commit or stash first. The tool edits in place across your whole
src. - Node 20 or newer. Run
npx @tailwindcss/upgrade. - Read the diff before trusting it. The class renames are mechanical and were right here; the config translation is where a custom theme needs your eyes.
- Build. If it fails on
Cannot apply unknown utility class ... missing @reference, add@reference "path/to/your/main.css";as the first line of every component<style>block that uses@apply. - If the next failure is
ENOENT ... tailwindcssand you are on Vite or Astro, install@tailwindcss/vite, wire it into the Vite plugins, and delete yourpostcss.config. - Check the generated
@custom-variant darkline against how you toggle dark mode. - Measure the stylesheet before and after. Expect it to grow.
FAQ
Is Tailwind v4 smaller than v3?
Not automatically. For the site measured here the main stylesheet grew from 8,473 to 10,825 bytes gzipped, a 28% increase, because v4 emits every default theme token as a CSS variable on :root whether a utility uses it or not.2 You can recover some of that by disabling unused colour families in @theme, but the default output is larger for a site that uses a small part of the palette.
Does npx @tailwindcss/upgrade handle everything?
Most of it. On this migration it updated dependencies, rewrote the PostCSS config, translated and then deleted tailwind.config.mjs, and renamed classes across 31 files, all correctly. It did not add the @reference lines that @apply needs inside Astro <style> blocks, and it did not move the project onto @tailwindcss/vite; both were required before the build would pass.
Why does @apply fail after upgrading to v4?
In v4, a stylesheet bundled separately from your main CSS file, which includes a <style> block in an Astro, Vue or Svelte component and any CSS module, has no access to your theme, custom utilities or custom variants.1 @apply there fails with "Cannot apply unknown utility class". Add @reference "path/to/main.css"; at the top of that block to import the context without duplicating output.3
Do I still need autoprefixer and postcss-import with Tailwind v4?
No. v4 bundles @import handling and does vendor prefixing through Lightning CSS.2 The upgrade tool removed autoprefixer on its own. postcss-import can go too; on Vite or Astro it comes from the bundler rather than your config, which is also why a bare @import "tailwindcss" reached by @reference can fail to resolve under the PostCSS plugin.
What happened to theme.container and its center and padding options in v4?
They were removed. The container utility still exists but is no longer configurable through the theme.1 The upgrade tool rewrites a v3 theme.container block into an @utility container rule in your CSS, keeping your actual values, for example margin-inline: auto; padding-inline: 1rem;. You edit that rule directly from then on.
How do I get class-based dark mode after migrating?
The upgrade tool writes @custom-variant dark (&:is(.dark *)) into your CSS in place of v3's darkMode: "class".6 If you toggle dark mode with a class on <html> and ever put a dark: utility on <html> itself, use @custom-variant dark (&:where(.dark, .dark *)) instead, which matches the toggled element and keeps specificity flat.5
Disclosure
The site migrated in this article is selim.services, one of our own. The migration and every measurement here are our own primary work on that codebase, not a reference to anything published on the site.
Sources
Checked 2026-09-03.
Sources
-
Tailwind CSS upgrade guide - that
npx @tailwindcss/upgraderequires Node 20 or newer and migrates dependencies, configuration and templates; that separately bundled stylesheets including Astro<style>blocks lose access to theme variables, custom utilities and custom variants in v4; thattheme.container'scenterandpaddingoptions no longer exist and can be replaced with an@utility containerrule; thatautoprefixerandpostcss-importcan be removed; and that Vite users should migrate from the PostCSS plugin to@tailwindcss/vite. -
Tailwind CSS v4.0 announcement - that v4 exposes all design tokens as CSS variables on
:rootby default, that@tailwindcss/viteis the recommended integration for Vite users, and that@importbundling and vendor prefixing are handled internally through Lightning CSS in place of external plugins. -
Tailwind CSS functions and directives: the @reference directive - that
@reference "main.css"imports theme variables, custom utilities and custom variants into a component<style>block or CSS module so that@applyresolves there, without duplicating CSS in the output. -
tailwindlabs/tailwindcss issue 18055: Astro site failing after upgrading Tailwind v3 to v4 - reports of utility classes failing to resolve after a v3 to v4 upgrade on Astro under the PostCSS plugin, with the dedicated Vite plugin as the path that resolves it.
-
Tailwind CSS dark mode - the
@custom-variant darkdeclarations for class-based and data-attribute dark mode, including the&:where(.dark, .dark *)form that also matches the toggled element. -
Tailwind CSS functions and directives: the @custom-variant directive - that
@custom-variantdefines a custom variant such as a class-baseddark.