Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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?

AreaTailwind 3Tailwind 4What to verify
Entry CSS@tailwind base/components/utilities@import "tailwindcss";One compiled stylesheet is loaded
PostCSStailwindcss plugin@tailwindcss/postcssNo stale plugin configuration
Themetheme.extend in JS config@theme CSS tokensEvery custom color and breakpoint resolves
File detectionExplicit content globsAutomatic source detectionClasses in generated or external packages are found
Dark modedarkMode: "class"@custom-variant darkTheme toggle and OS preference do not fight
Custom utilitiesSpecial @layer utilities behavior@utility for registered utilitiesResponsive and state variants still work
BordersDefault gray borderDefault currentColorDividers do not disappear or darken
RingsDifferent default width/colorWidth and color defaults changedKeyboard focus remains visible
HoverHover selectors may fire on touchHover variant respects hover-capable devicesTouch 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”:

SurfaceLight modeDark modeMobileKeyboard
Header and navigationContrast and dividersMenu contrastDrawer width and overflowEscape, focus order
Product cardBorders and image cropBadge colorsGrid wrapPreview action focus
FormsPlaceholder and validationInput surfaceFull-width controlsError announcement
ModalOverlay and scroll lockPanel contrastViewport fitFocus return
Pricing CTAButton hierarchyContrastTap targetFocus 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

SymptomLikely causeNext check
Build says Tailwind cannot run as a PostCSS pluginOld tailwindcss plugin entryInstall and configure @tailwindcss/postcss
Custom bg-primary produces no CSSToken not in processed @theme or source not scannedInspect CSS import and generated stylesheet
Dark mode follows the OS instead of the toggleNo class-based @custom-variantCheck the CSS variant and root HTML class
Card borders look darkerBorder default changedSet an explicit border-* color
Focus indicator is faintRing or outline defaults changedSet width, color, and offset explicitly
Responsive layout differs at 425pxToken and authored media query divergedCompare both declarations
Hover menu works on desktop, not touchHover is no longer a touch substituteProvide a click/tap interaction
Old custom class ignores hover:Native @layer is not a registered utilityUse @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.

Keep reading

Tutorial8 min read

Tailwind Aspect Ratio: 22 Uses, Zero of Them aspect-video

Why this codebase writes aspect-[16/9] instead, and how the card's ratio has to agree with the img's width, height and the generated file's width.

Read more
Tutorial9 min read

Tailwind Button: 15 Utilities, 3 Variants, and Zero Buttons

The component called Button renders a link all 22 times it is used, while 44 real button elements sit elsewhere. The full class list, and the disabled state it was missing.

Read more