Tailwind 3 to 4: Migration Checklist and Visual Regression Audit
Upgrade Tailwind CSS v3 to v4 safely: verify PostCSS, theme tokens, dark mode, borders, focus states, source scanning, and visual regressions.
Migrating from Tailwind CSS 3 to 4 is not just changing a package version. Run the official upgrade tool on a clean branch, then audit browser support, CSS entry points, border and ring defaults, dark-mode selectors, class detection, and visual regressions. This checklist uses a real Tailwind v4 storefront as the destination architecture—not as a claimed migration benchmark.
Before you upgrade: decide whether v4 is supported
Tailwind v4 targets modern browser engines: Safari 16.4+, Chrome 111+, and Firefox 128+. If your product contract still requires older browsers, staying on Tailwind 3.4 is a valid engineering decision. A successful build on your laptop cannot change the CSS features the browser implements.
Use a branch with a clean working tree. First make sure the existing v3 application builds and its critical screens are captured: header, product cards, forms, modal, checkout, dark mode, and responsive layouts. Without a baseline, a v4 screenshot difference becomes an argument about whether the old layout was intentional.
The official Tailwind upgrade guide recommends the automated tool and a manual review. It requires Node.js 20 or newer. Run the upgrade before making unrelated design-system changes, so the diff has one cause.
git switch -c upgrade/tailwind-v4
npm test
npm run build
npx @tailwindcss/upgrade
git diff --stat
git diff -- package.json postcss.config.mjs src/app/globals.css
These commands are an upgrade procedure, not a command transcript from this repository. The current ASoc codebase already uses Tailwind v4; no before-and-after migration timings are claimed.
What actually changes between Tailwind 3 and 4?
| Area | Tailwind 3 | Tailwind 4 | What to verify |
|---|---|---|---|
| Entry CSS | @tailwind base/components/utilities | @import "tailwindcss"; | One compiled stylesheet is loaded |
| PostCSS | tailwindcss plugin | @tailwindcss/postcss | No stale plugin configuration |
| Theme | theme.extend in JS config | @theme CSS tokens | Every custom color and breakpoint resolves |
| File detection | Explicit content globs | Automatic source detection | Classes in generated or external packages are found |
| Dark mode | darkMode: "class" | @custom-variant dark | Theme toggle and OS preference do not fight |
| Custom utilities | Special @layer utilities behavior | @utility for registered utilities | Responsive and state variants still work |
| Borders | Default gray border | Default currentColor | Dividers do not disappear or darken |
| Rings | Different default width/color | Width and color defaults changed | Keyboard focus remains visible |
| Hover | Hover selectors may fire on touch | Hover variant respects hover-capable devices | Touch interactions work without hover |
This is a migration audit, not a repeat of the Tailwind v4 configuration reference. That article explains where configuration lives. Here the question is which behavior can change while the application still compiles.
The destination architecture in a real storefront
ASoc's package.json uses tailwindcss v4, @tailwindcss/postcss v4, and prettier-plugin-tailwindcss. The actual postcss.config.mjs is short enough to reproduce in full:
// postcss.config.mjs — current repository code
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
There is no tailwindcss PostCSS plugin entry and no autoprefixer entry. Keeping the v3 plugin declaration while upgrading the Tailwind package is a common build-time failure; changing only the dependency version is not sufficient.
The stylesheet's opening lines are equally concrete:
/* src/app/globals.css — current repository code */
@import "tailwindcss";
/* Class-based dark mode (toggled by adding dark to html) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-primary: #465fff;
--color-primary-500: #465fff;
--color-primary-600: #3641f5;
--breakpoint-2xsm: 375px;
--breakpoint-xsm: 425px;
}
The excerpt is a selection from the real file, not a complete replacement stylesheet. The two custom breakpoints and the brand colors are actual tokens. That is first-party evidence for what a working v4 destination looks like, but it does not prove that every v3 project can adopt these values unchanged.
Step 1: verify imports, PostCSS, and class detection
Start at the CSS entry. Remove the old three @tailwind directives only after confirming the new import is processed by the build. Check that postcss.config.mjs points to the v4 package. For a Vite project, Tailwind recommends its dedicated Vite plugin; ASoc is a Next.js project, so its PostCSS path is appropriate.
Next inspect the classes that come from places outside ordinary app files: a shared component package, a generated HTML file, or a vendored template. Automatic detection is helpful but not magic. If a source is excluded or lives outside the detection roots, declare it explicitly with @source. If your old safelist protected classes constructed at runtime, move to literal complete class names or an explicit @source inline() list rather than hoping the scanner evaluates JavaScript.
A risky pattern is "bg-" + color. Tailwind's scanner sees string fragments, not the runtime value. Prefer a fixed map:
// Migration illustration — not a copied ASoc component
const toneClass = {
success: "bg-green-50 text-green-800",
warning: "bg-amber-50 text-amber-900",
danger: "bg-red-50 text-red-800",
} as const;
type Tone = keyof typeof toneClass;
export function Notice({ tone, children }: { tone: Tone; children: React.ReactNode }) {
return <div className={toneClass[tone]}>{children}</div>;
}
That map is deliberately illustrative. The real repository's custom token values live in globals.css, and its component classes are written as complete strings. The rule is independent of whether the app uses Next.js, Vue, or plain HTML.
Step 2: audit borders, rings, and focus before visual polish
A green TypeScript check cannot tell you that a card divider now uses a different color. Tailwind v4 changed the default border and ring behavior. Search for bare border, divide-y, ring, and focus outlines. Replace ambiguous defaults with explicit tokens where the visual design depends on a color or width.
The ASoc header uses bg-white shadow-sm dark:bg-gray-900, while its buttons and forms use explicit border and focus utilities. That is the kind of surface you should review in both themes. An especially important case is keyboard focus: a ring that becomes too thin or inherits the wrong color is an accessibility regression, not a harmless cosmetic change.
// Migration illustration: make the intended states explicit
<button
type="button"
className="rounded-lg border border-gray-300 px-4 py-2
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-blue-600"
>
Continue
</button>
Do not remove a visible focus indicator merely to match a screenshot. Test with the keyboard: Tab into the control, activate it with Enter or Space, and check the outline against both light and dark backgrounds. The Tailwind focus-visible guide covers the interaction pattern; this migration audit owns the question of whether an upgrade changed it.
Step 3: treat dark mode as an application contract
Tailwind v4's default dark: behavior follows prefers-color-scheme. A site with a manual class toggle must override that behavior. ASoc does so with the @custom-variant line shown above. Its src/app/layout.tsx also runs a small synchronous script in the document head to restore the saved theme preference before the first paint.
Those are two separate responsibilities. The CSS decides when a dark utility matches; the script decides which preference the document starts with. A project can migrate the first and accidentally leave the second behind, producing a light-to-dark flash or a toggle that appears inert.
If your actual goal is to make the site light-only, use the narrower disable dark mode in Tailwind v4 decision tree. Do not copy the class-based setup into a site that never offers a dark theme.
Step 4: review custom utilities and spacing assumptions
In v3, custom rules inside Tailwind's special @layer utilities could participate in generated variants. In v4, @layer is native CSS layering. Use @utility when you genuinely need a named utility that responds to variants; keep ordinary component selectors as ordinary CSS.
ASoc's globals.css deliberately keeps a handwritten .container selector with explicit media queries. Its --breakpoint-xsm: 425px theme token does not automatically update the handwritten @media (min-width: 425px) rule. If you change the breakpoint, review both. The same lesson applies to any migration that mixes generated utilities with authored CSS: two declarations that look related may have independent sources of truth.
Check space-x-* and divide-* lists with conditional or hidden children. Prefer a parent flex or grid plus gap-* when it better describes the intended layout. Also review stacked variants and gradient stop behavior, which changed in v4. A migration codemod cannot decide whether the visual result is correct for your product.
Step 5: run a screen-level regression matrix
Make a small, repeatable checklist instead of asking one developer to “click around”:
| Surface | Light mode | Dark mode | Mobile | Keyboard |
|---|---|---|---|---|
| Header and navigation | Contrast and dividers | Menu contrast | Drawer width and overflow | Escape, focus order |
| Product card | Borders and image crop | Badge colors | Grid wrap | Preview action focus |
| Forms | Placeholder and validation | Input surface | Full-width controls | Error announcement |
| Modal | Overlay and scroll lock | Panel contrast | Viewport fit | Focus return |
| Pricing CTA | Button hierarchy | Contrast | Tap target | Focus ring |
The first-party src/components/organisms/Header.tsx includes a mobile drawer with Escape handling and scroll locking. That is a useful migration test target because it mixes stateful behavior, responsive classes, overlays, and dark styles in one component. The matrix is a suggested test plan; no unrun screenshots are presented as measured evidence.
For an app with snapshot or visual regression tests, compare screenshots at fixed viewport widths. For one without them, record the browser, OS theme, viewport, and expected result in the PR. Test at least one touch device because v4's hover variant intentionally behaves differently there.
Step 6: separate build correctness from visual correctness
Run the same checks before and after the upgrade:
npm test
npm run lint
npm run format:check
npm run build
These are the actual scripts exposed by this repository's package.json. They catch broken imports, invalid metadata, type/build errors, and formatting drift. They do not verify whether a button became invisible, a layout overflows at 375px, or an OS theme overrides a saved preference. The browser matrix is a second gate, not a replacement for the first.
For a production migration, ship behind a reversible deployment or keep the previous release ready. A CSS-only regression can affect every route while leaving all unit tests green. Record exactly which screen was checked and which could not be checked.
Troubleshooting after a v3-to-v4 upgrade
| Symptom | Likely cause | Next check |
|---|---|---|
| Build says Tailwind cannot run as a PostCSS plugin | Old tailwindcss plugin entry | Install and configure @tailwindcss/postcss |
Custom bg-primary produces no CSS | Token not in processed @theme or source not scanned | Inspect CSS import and generated stylesheet |
| Dark mode follows the OS instead of the toggle | No class-based @custom-variant | Check the CSS variant and root HTML class |
| Card borders look darker | Border default changed | Set an explicit border-* color |
| Focus indicator is faint | Ring or outline defaults changed | Set width, color, and offset explicitly |
| Responsive layout differs at 425px | Token and authored media query diverged | Compare both declarations |
| Hover menu works on desktop, not touch | Hover is no longer a touch substitute | Provide a click/tap interaction |
Old custom class ignores hover: | Native @layer is not a registered utility | Use @utility where appropriate |
Frequently asked questions
Can I upgrade Tailwind 3 to 4 without changing my components? Sometimes the codemod handles the mechanical changes, but changed defaults still require a visual audit. Treat “compiles” and “looks correct” as different outcomes.
Does Tailwind 4 require a JavaScript config file? No. A CSS-first setup is normal. A legacy JS config can be referenced explicitly with @config when needed; see Tailwind v4 config for that separate topic.
Will Tailwind 4 make my site faster? Do not assume an improvement without comparing the same route, hardware, and network conditions. This article makes no speed claim for ASoc's upgrade because no paired v3/v4 benchmark was recorded.
Should I migrate a large design system all at once? Prefer one isolated branch, explicit component audits, and a rollback path. If multiple applications share CSS packages, stage adoption so a change in one app does not silently change every consumer.
Sources and next steps
The authoritative migration rules are in Tailwind's upgrade guide, dark mode documentation, and custom styles documentation. The first-party destination examples above come from src/app/globals.css, postcss.config.mjs, src/app/layout.tsx, and src/components/organisms/Header.tsx.
If you want to study a complete code-owned landing-page structure after migration, explore ASoc Ledger, ASoc Lens, and ASoc Magnet. The Next.js landing-page templates and Tailwind landing-page templates are the broader destinations.
