Legacy Projects
When staying on Tailwind v3.4 is the right call, running v3 and v4 side by side, the @config escape hatch, and reading v3-era tutorials safely.
Not every project should be on v4, and not every project can move in one step. This page is about the in-between: deciding to stay, migrating gradually, and working in a world where most of the Tailwind content on the internet is still about v3.
When staying on v3.4 is the right call#
v3.4 is the final v3 line and it still works. There are three honest reasons to stay on it.
You need browsers v4 does not support#
v4 is built on @property, color-mix() and cascade layers, so it requires Safari 16.4+, Chrome 111+, and Firefox 128+. There is no polyfill, no legacy build and no compatibility mode.
Check before you plan anything else. Every other item on this page assumes the browser floor is acceptable.
You depend on a JavaScript plugin nobody has ported#
v4 loads legacy plugins through @plugin, and most of the ecosystem works. But a plugin that reaches into v3 internals — reading the resolved config, generating variants through the old plugin API, or shipping its own corePlugins assumptions — may simply not run.
If that plugin is load-bearing and unmaintained, your migration is really a "replace the plugin" project wearing a migration costume. Size that work honestly before you start.
The codebase is frozen#
A project in maintenance mode, with no roadmap and a team that has moved on, gets nothing from v4 and takes on real regression risk. A dependency upgrade that changes what border, ring and shadow mean is not a safe change to make in a codebase nobody is actively testing.
| Situation | Recommendation |
|---|---|
| Actively developed, modern browser floor | Upgrade. v3 → v4 Migration |
| Actively developed, must support Safari 15 | Stay on v3.4 until the floor moves |
| Maintenance mode, occasional security patches | Stay on v3.4, pin the version |
| Being rewritten in the next two quarters | Stay, and start the rewrite on v4 |
| A monorepo with a mix of the above | Both — see Running v3 and v4 side by side |
Keeping a v3 project healthy#
Staying on v3 is a decision, and undocumented decisions get reversed by the next person with a dependency dashboard.
- Pin the version. An exact
"tailwindcss": "3.4.x"inpackage.json, not a caret range that a lockfile refresh could nudge. - Say why, in the repository. A short note in the README and a comment at the top of
tailwind.config.js— the browser floor, the blocking plugin, or the freeze date. - Configure the bot. Tell Dependabot or Renovate to ignore major bumps of
tailwindcss, so the decision is not re-litigated in every PR queue. - Bookmark the right docs. v3 documentation lives at
v3.tailwindcss.com; the main site documents v4 and will quietly mislead you. - Set a review date. Browser floors move on their own. Put a calendar reminder on it rather than waiting for someone to ask.
Mark v3 code where people will actually see it. A comment at the top of the config is worth more than a paragraph in a wiki:
// Tailwind v3.4 — deliberately not upgraded.
// v4 requires Safari 16.4+; we still support Safari 15 (see ADR-014).
// Review: 2027-01. Docs: https://v3.tailwindcss.com
module.exports = {
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
// …
}Running v3 and v4 side by side#
In a monorepo, package managers resolve dependencies per workspace, so one app can be on v3.4 while another is on v4 with no conflict. Each app has its own CSS entry point, its own build step and its own compiled stylesheet.
apps/
admin/ → tailwindcss@3.4 · src/styles.css with @tailwind directives
storefront/ → tailwindcss@4 · src/app.css with @import "tailwindcss"
packages/
ui/ → no Tailwind dependency; ships components with class stringsTwo rules make this workable:
- Never load both compiled stylesheets on the same page. You get two Preflights fighting each other, and worse, two different definitions of
shadow-sm,rounded-smandblur-sm. Whichever loads last wins, inconsistently. - Keep shared packages version-agnostic. A shared UI package that ships class strings has to pick a vocabulary. Use only classes that mean the same thing in both versions, or fork the component during the transition.
If you genuinely must render v3 and v4 output on one page — a gradually migrated legacy shell, say — prefix one of them so the class namespaces cannot collide:
module.exports = { prefix: 'lg-' } // produces lg-flex, hover:lg-bg-red-500@import "tailwindcss" prefix(tw); /* produces tw:flex, tw:hover:bg-red-500 */Note that the prefix moved position between versions: v3 prefixes the utility, v4 prefixes the whole class like a variant.
The @config escape hatch#
v4 does not read tailwind.config.js automatically, but it will if you ask:
@import "tailwindcss";
@config "../../tailwind.config.js";This is the fastest way to get a large v3 project compiling under v4: switch the packages and the entry point, point @config at the file you already have, and deal with the theme conversion later.
Treat @config as a way to keep your theme and plugins working while you convert them, not as a supported long-term configuration style. Verify anything beyond those in the generated CSS rather than assuming it carried over, and convert the file to @theme before you call the migration done — see Theme & Design Tokens.
Incremental migration strategies#
The unit of migration is a compiled stylesheet, because that is the smallest thing that can be on one version or the other. Pick the strategy that matches how your CSS is bundled.
Per app, in a monorepo#
The cleanest split, and the only one with no shared surface. Migrate in order of risk:
- An internal tool first. Low traffic, forgiving users, real complexity — you learn the actual failure modes without a customer seeing them.
- Then a marketing or docs site. Visually rich, structurally simple, easy to review by eye.
- Then the main product. By now you know exactly which components need re-reviewing.
- The shared UI package last. Migrate it only when every consumer is on v4, or you will be maintaining two vocabularies at once.
Per route or per entry point#
Workable when routes ship their own CSS bundle — multi-page apps, Rails and Django with separate packs, Astro, or a Vite build with multiple entries. Move one route to a v4 stylesheet, keep the rest on the v3 one, and never load both on the same page.
This does not work in a single-page app with one global stylesheet. There, the unit is the whole app.
Theme first, templates later#
Inside a single app, convert in two passes rather than one:
- Pass one — get it building. Swap the packages, replace the
@tailwinddirectives with@import "tailwindcss", and point@configat the existing config. Fix only what breaks the build. - Pass two — the defaults audit. Borders, rings, outlines, dividers and the renamed size scales, one family at a time, each its own commit.
- Pass three — retire the config. Move the theme into
@themenamespace by namespace, delete@config, deletetailwind.config.js.
Freeze feature work on the affected screens while you do this. A migration branch that also contains new features is a branch nobody can review.
Reading v3-era tutorials safely#
Most Tailwind content online — blog posts, Stack Overflow answers, course videos, AI-generated snippets — was written for v3. It is still useful; you just have to translate as you read.
Three tells that you are looking at v3 content: a tailwind.config.js file, @tailwind base;, or npx tailwindcss init. Any one of them means check every class name in the snippet before trusting it.
| v3 idiom you will see online | v4 equivalent |
|---|---|
npx tailwindcss init -p | No config file — create a CSS file with @import "tailwindcss"; |
@tailwind base; @tailwind components; @tailwind utilities; | @import "tailwindcss"; |
plugins: { tailwindcss: {}, autoprefixer: {} } in postcss.config.js | plugins: { "@tailwindcss/postcss": {} } — no autoprefixer, no postcss-import |
<script src="https://cdn.tailwindcss.com"> | <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"> |
content: ['./src/**/*.{html,js}'] | Automatic source detection, plus @source for anything outside it |
theme.extend.colors.brand | --color-brand-* inside @theme |
theme.extend.screens | --breakpoint-* inside @theme |
plugins: [require('@tailwindcss/typography')] | @plugin "@tailwindcss/typography"; |
darkMode: 'class' | @custom-variant dark (&:where(.dark, .dark *)); |
@layer utilities { .content-auto { … } } | @utility content-auto { … } |
theme('colors.red.500') in CSS | var(--color-red-500) |
theme('screens.xl') in a media query | theme(--breakpoint-xl) |
resolveConfig(config) in JS | getComputedStyle(document.documentElement).getPropertyValue('--color-red-500') |
class="shadow rounded" | class="shadow-sm rounded-sm" |
class="blur" | class="blur-sm" |
class="ring ring-blue-500" | class="ring-3 ring-blue-500" |
class="focus:outline-none" (to hide an outline) | class="focus:outline-hidden" |
class="bg-opacity-50" | class="bg-black/50" |
class="flex-grow flex-shrink-0" | class="grow shrink-0" |
class="overflow-ellipsis" | class="text-ellipsis" |
class="bg-gradient-to-r" | class="bg-linear-to-r" |
class="!flex" | class="flex!" |
class="bg-[--brand]" | class="bg-(--brand)" |
class="grid-cols-[max-content,auto]" | class="grid-cols-[max-content_auto]" |
class="first:*:pt-0" | class="*:first:pt-0" |
@apply in a Vue or Svelte <style> block | Add @reference "../../app.css"; above it |
The container plugin's @md: variants | @container and @md: in core — uninstall the plugin |
The full list, with what each thing became and why, is in Deprecated APIs.
The v3 examples in this repository#
The original example pages this handbook grew out of are kept, unchanged, under legacy/v3-examples/.
Everything under content/ is written against Tailwind v4.3 and is the current guidance. If the two disagree, the archive is wrong by definition.
Common mistakes#
- Upgrading before checking the browser floor. Safari 16.4+, Chrome 111+ and Firefox 128+ is a hard requirement. Everything else on a migration plan is downstream of it.
- Staying on v3 without writing down why. An unmarked v3 repository gets v4 syntax pasted into it by the next contributor, and nobody can explain why nothing works.
- Loading a v3 and a v4 stylesheet on the same page. Two Preflights, and two conflicting definitions of
shadow-sm,rounded-smandblur-sm. - Treating
@configas the finish line. It keeps your theme working; it silently dropscorePlugins,safelistandseparator, and it changes nothing about class names or defaults. - Migrating the shared UI package first. Do it last, when every consumer is already on v4 — otherwise you maintain two class vocabularies at once.
- Copying from
legacy/v3-examples/. Those pages are an archive, not documentation. They use the v3 CDN and v3 config on purpose. - Reading the main Tailwind site while working on a v3 project. It documents v4. v3 documentation is at
v3.tailwindcss.com.
Related topics#
- v3 → v4 Migration — the upgrade tool, the manual steps and the behaviour changes
- Deprecated APIs — every removed, renamed and deprecated API in one place
- Theme & Design Tokens — converting a config file to
@theme - Installation — the v4 packages and the current Play CDN
- Plugins —
@plugin, and what to do about an unported one