{"id":418,"date":"2026-07-04T13:01:38","date_gmt":"2026-07-04T13:01:38","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-v3-to-v4-migration-what-actually-broke\/"},"modified":"2026-07-04T13:01:38","modified_gmt":"2026-07-04T13:01:38","slug":"tailwind-v3-to-v4-migration-what-actually-broke","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-v3-to-v4-migration-what-actually-broke\/","title":{"rendered":"Tailwind v3 to v4 Migration: What Actually Broke in My Projects"},"content":{"rendered":"<p>Confession: I put off the v4 upgrade for four months. Not because v4 looked bad. I read the announcement post the day it dropped. It was because I had a Next.js app in production that leaned on maybe fifteen Tailwind plugins, half of which hadn&rsquo;t been touched since 2023. I told myself I&rsquo;d wait for &ldquo;the ecosystem to catch up,&rdquo; which is the excuse I use for every framework upgrade I don&rsquo;t want to do.<\/p>\n<p>Then last weekend I finally sat down and did it. Two projects, a lot of coffee, roughly one Saturday. Most of it went fine. But there were about six things that quietly broke in ways the upgrade tool didn&rsquo;t catch, and I want to write those down before I forget them.<\/p>\n<p>If you&rsquo;re still on v3 and eyeing the <a href=\"https:\/\/tailwindcss.com\/docs\/upgrade-guide\" rel=\"nofollow noopener\" target=\"_blank\">official upgrade guide<\/a>, treat this as the sidecar post: what actually bit me, in the order it bit me.<\/p>\n<h2 id=\"the-tailwind-directives-are-gone-and-yes-it-matters\">The @tailwind directives are gone, and yes it matters<\/h2>\n<p>The first thing v4 does is rip out the three lines every Tailwind project starts with. In v3 my <code>globals.css<\/code> had:<\/p>\n<pre><code class=\"language-css\">@tailwind base;\n@tailwind components;\n@tailwind utilities;\n<\/code><\/pre>\n<p>In v4, that whole block collapses into a single import:<\/p>\n<pre><code class=\"language-css\">@import &quot;tailwindcss&quot;;\n<\/code><\/pre>\n<p>The upgrade CLI handled this fine on both projects. What it didn&rsquo;t handle was the fact that I had a custom CSS layer sitting between <code>@tailwind base<\/code> and <code>@tailwind components<\/code> where I patched a few resets. When it collapsed everything into one import, my layer ordering flipped and my custom reset ran after Tailwind&rsquo;s, which meant my body font-family got clobbered on the marketing site.<\/p>\n<p>Fix: put overrides inside an explicit <code>@layer<\/code> block so ordering is deterministic.<\/p>\n<pre><code class=\"language-css\">@import &quot;tailwindcss&quot;;\n\n@layer base {\n  body { font-family: &quot;Inter Variable&quot;, system-ui, sans-serif; }\n}\n<\/code><\/pre>\n<p>Ten minutes of debugging that could have been zero if I&rsquo;d read one paragraph of the migration guide. The <a href=\"https:\/\/tailwindcss.com\/blog\/tailwindcss-v4\" rel=\"nofollow noopener\" target=\"_blank\">official v4 announcement<\/a> is worth reading before you touch anything.<\/p>\n<h2 id=\"the-config-file-didnt-move-it-dissolved\">The config file didn&rsquo;t move, it dissolved<\/h2>\n<p>The bigger conceptual change is that <code>tailwind.config.js<\/code> is optional in v4. Theme tokens live in CSS now, inside a <code>@theme<\/code> block. I wrote a whole separate post about <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-css-v4-config-file-i-deleted\" rel=\"noopener\">the Tailwind CSS v4 config file I deleted<\/a> so I won&rsquo;t relitigate the philosophy here, but the migration shape looks like this.<\/p>\n<p>Before, in <code>tailwind.config.js<\/code>:<\/p>\n<pre><code class=\"language-js\">module.exports = {\n  theme: {\n    extend: {\n      colors: {\n        brand: {\n          50: &quot;#f4f8ff&quot;,\n          500: &quot;#3358ff&quot;,\n          900: &quot;#0a1a5a&quot;,\n        },\n      },\n      fontFamily: {\n        display: [&quot;Cabinet Grotesk&quot;, &quot;sans-serif&quot;],\n      },\n    },\n  },\n};\n<\/code><\/pre>\n<p>After, in <code>globals.css<\/code>:<\/p>\n<pre><code class=\"language-css\">@import &quot;tailwindcss&quot;;\n\n@theme {\n  --color-brand-50: #f4f8ff;\n  --color-brand-500: #3358ff;\n  --color-brand-900: #0a1a5a;\n  --font-display: &quot;Cabinet Grotesk&quot;, sans-serif;\n}\n<\/code><\/pre>\n<p>The mapping is: <code>theme.extend.colors.brand.500<\/code> becomes the CSS variable <code>--color-brand-500<\/code>, and Tailwind generates <code>bg-brand-500<\/code>, <code>text-brand-500<\/code>, and the rest from it. Same for <code>fontFamily.display<\/code> \u2192 <code>--font-display<\/code> \u2192 <code>font-display<\/code>.<\/p>\n<p>Two things bit me here.<\/p>\n<p>First, I had a JS-based color palette generator wired into my old config (chroma.js, do not ask). That doesn&rsquo;t work anymore because there&rsquo;s no JS execution step in v4&rsquo;s config path. I ended up writing a small Node script that computes the color ramp once at build time and emits a <code>theme.css<\/code> file that I <code>@import<\/code> before the main Tailwind import.<\/p>\n<p>Second, I lost my <code>plugins: [require(\"@tailwindcss\/typography\")]<\/code> line and forgot to add it back. Prose styles just weren&rsquo;t there. The v4 way is to add <code>@plugin<\/code> inside the CSS: <code>@plugin \"@tailwindcss\/typography\";<\/code>. Another five minutes of &ldquo;why is my blog body ugly again.&rdquo;<\/p>\n<h2 id=\"the-utility-renames-that-quietly-changed-my-design\">The utility renames that quietly changed my design<\/h2>\n<p>This is the one that made me swear at my laptop.<\/p>\n<p>In v3, <code>shadow-sm<\/code> was a subtle shadow and <code>shadow<\/code> was medium. In v4 the scale shifted: what was <code>shadow-sm<\/code> is now <code>shadow-xs<\/code>, and what was plain <code>shadow<\/code> is now <code>shadow-sm<\/code>. Same story for parts of the <code>blur<\/code>, <code>rounded<\/code>, and <code>outline<\/code> scales.<\/p>\n<p>The upgrade CLI rewrites most of these in your source files. It does not rewrite them inside strings that get concatenated at runtime. My admin dashboard had a component like this:<\/p>\n<pre><code class=\"language-tsx\">const size = compact ? &quot;sm&quot; : &quot;md&quot;;\nreturn &lt;div className={`shadow-${size} rounded-${size}`}&gt;...&lt;\/div&gt;;\n<\/code><\/pre>\n<p>The CLI can&rsquo;t statically see <code>shadow-${size}<\/code>, so it left it alone. Result: my dashboard&rsquo;s card shadows got heavier overnight because <code>shadow-sm<\/code> in v4 renders like v3&rsquo;s <code>shadow<\/code>. Nothing crashed. Nothing threw a warning. The design just drifted.<\/p>\n<p>Fix: I scanned my codebase for string interpolation in <code>className<\/code> values and switched to a static lookup table. In hindsight I should never have been doing runtime className concatenation for design tokens, but v3 was permissive and I got lazy.<\/p>\n<p>If you&rsquo;re doing this at scale, <code>grep -rn 'className={<\/code>[^<code>]*${' src\/<\/code> is a decent first pass to find the interpolations you actually need to fix. It&rsquo;ll miss some (template literals split across lines) but it catches the obvious ones.<\/p>\n<h2 id=\"postcss-is-out-vite-is-in-npm-scripts-change\">PostCSS is out, Vite is in, npm scripts change<\/h2>\n<p>On the smaller of my two projects I was using PostCSS with a hand-written config. v4 ships a proper Vite plugin, and Tailwind&rsquo;s team basically wants you to switch to it if you&rsquo;re on Vite. The migration looks like this.<\/p>\n<p>Before:<\/p>\n<pre><code class=\"language-js\">\/\/ postcss.config.js\nmodule.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n<\/code><\/pre>\n<p>After:<\/p>\n<pre><code class=\"language-ts\">\/\/ vite.config.ts\nimport tailwindcss from &quot;@tailwindcss\/vite&quot;;\n\nexport default {\n  plugins: [tailwindcss()],\n};\n<\/code><\/pre>\n<p>Build times on this project dropped from about 4.1s to 1.6s on a cold build, which lined up roughly with what I saw when I first tried <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-v4-oxide-engine-build-times-i-stopped-babysitting\" rel=\"noopener\">the Oxide engine on its own<\/a>. Not a placebo, not sponsored, just faster.<\/p>\n<p>The Next.js project was slightly more annoying because Next uses its own PostCSS pipeline. There the answer is the PostCSS plugin (<code>@tailwindcss\/postcss<\/code>), which is supported and works fine. If you&rsquo;re on Next 15+ you don&rsquo;t need to touch anything except swap the plugin name.<\/p>\n<h2 id=\"browser-support-is-now-a-real-conversation\">Browser support is now a real conversation<\/h2>\n<p>v4 drops IE 11 (obviously) but also raises the floor on modern browsers. It leans on <code>@property<\/code>, cascade layers, <code>color-mix()<\/code>, and container queries. All of those need Safari 16.4+, Firefox 128+, and Chromium 111+.<\/p>\n<p>For my marketing site, our analytics said 0.4% of traffic was on browsers below that line, so I moved on. For the admin app, I checked with the ops team and we&rsquo;re on evergreen Chrome only, so also fine. If you have a compliance client on locked-down browsers, this is the one thing worth verifying before you upgrade. Tailwind is explicit that v3 stays supported for anyone who needs to hold the line.<\/p>\n<p>Under the hood, v4 uses <a href=\"https:\/\/lightningcss.dev\/\" rel=\"nofollow noopener\" target=\"_blank\">Lightning CSS<\/a> instead of PostCSS + autoprefixer, which is part of why the build is so much faster and also part of why old browser targets don&rsquo;t work. Lightning CSS decides what to prefix based on your browserslist, and Tailwind&rsquo;s baseline is stricter than autoprefixer&rsquo;s default.<\/p>\n<h2 id=\"the-dev-experience-is-quietly-a-lot-nicer\">The dev experience is quietly a lot nicer<\/h2>\n<p>I want to close with the thing that surprised me most, because it&rsquo;s the part the release notes undersell.<\/p>\n<p>The dev server just feels faster. Not &ldquo;here&rsquo;s a benchmark faster.&rdquo; Subjectively faster in the loop. HMR on a Tailwind class change was noticeable in v3, and it&rsquo;s basically instant in v4. Combined with the CSS-based config, my whole loop is: edit a class, save, see it. No config file to remember. No restart when I change theme tokens. The build step stopped feeling like a thing.<\/p>\n<p>The other quiet win is that container queries are first-class now. I&rsquo;d been half-heartedly using them via a plugin in v3 and it always felt bolted on. v4 makes them the default way to think about component-level responsiveness, which is a shift I wrote about in more detail in <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-v4-container-queries-the-media-queries-i-stopped-writing\" rel=\"noopener\">the container queries post<\/a>. If you&rsquo;re building any kind of dashboard or component library, that one change alone justifies the upgrade for me.<\/p>\n<p>I keep a running log of these kinds of migration notes on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work page<\/a> because I refer back to them more often than I&rsquo;d like to admit.<\/p>\n<h2 id=\"what-to-actually-do-this-week\">What to actually do this week<\/h2>\n<p>If you&rsquo;re on v3 and thinking about it: pick your smallest project, run the CLI, and just do it. You&rsquo;ll hit two or three of the things above, they&rsquo;ll all be fixable in an afternoon, and you&rsquo;ll come out with a faster build and a config file that lives where it should have lived all along.<\/p>\n<p>If you&rsquo;re on v3 and can&rsquo;t move (design system frozen, third-party plugin stuck, weird browser requirement), stay on v3. It&rsquo;s not going anywhere and Tailwind&rsquo;s maintenance policy is generous. The upgrade will still be there when your constraints change.<\/p>\n<p>Either way, do yourself a favor and read the <a href=\"https:\/\/tailwindcss.com\/docs\/upgrade-guide\" rel=\"nofollow noopener\" target=\"_blank\">upgrade guide<\/a> end-to-end before you start. I skipped that step and it cost me an hour I could have spent doing almost anything else.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>My real Tailwind v3 to v4 migration notes: the utility renames, the CSS-first config, the PostCSS pipeline change, and every place my styles quietly broke.<\/p>\n","protected":false},"author":2,"featured_media":417,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"My real Tailwind v3 to v4 migration notes: the utility renames, the CSS-first config, the PostCSS pipeline change, and every place my styles quietly broke.","rank_math_focus_keyword":"tailwind css v4 migration guide","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[137,138,433],"tags":[37,38,40,139,456,455,172],"class_list":["post-418","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-css","category-frontend","category-tailwind-css","tag-css","tag-frontend","tag-migration","tag-tailwind","tag-tailwind-css-v4","tag-tailwindcss","tag-web-development-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/418","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=418"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/418\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/417"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=418"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=418"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=418"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}