TechSetupGuides
Intermediatetailwindcssfrontendmigrationpostcssvite

Tailwind CSS v4: migrating from v3

A complete v3 to v4 migration: the CSS-first @theme config, the automated upgrade tool, and every silent default change that ships a visual regression without erroring.

  1. Step 1

    Check your browser and Node targets first

    Tailwind v4 is built on modern CSS — @property, color-mix(), cascade layers — and it does not polyfill them. The supported floor is Safari 16.4+, Chrome 111+, and Firefox 128+. If your analytics still show meaningful traffic below those versions, stay on v3.4; there is no compatibility mode. The automated upgrade tool also requires Node.js 20 or newer.

    # Confirm Node 20+ before doing anything else
    node --version
    
    # Check what you're currently on
    npm ls tailwindcss
    ⚠ Heads up: There is no v4 build target for older browsers. Verify your traffic before migrating — this is a one-way door for IE-era or older Safari users.
  2. Step 2

    Run the migration on a clean branch

    The upgrade tool rewrites CSS, config, and template files in place. Commit or stash everything first so git diff shows you exactly what it changed — reviewing that diff is the single most valuable step in the whole migration.

    git checkout -b tailwind-v4
    git status   # must be clean
    
    npx @tailwindcss/upgrade
  3. Step 3

    What the upgrade tool does and does not do

    It handles the mechanical work: swapping dependencies, converting tailwind.config.js into CSS @theme variables, and renaming utility classes across your templates. What it cannot do is find class names your code builds at runtime — string concatenation, template literals, or class maps in a CMS. Those you must grep for by hand.

    # Classes assembled at runtime are invisible to the codemod.
    # This will NOT be migrated for you:
    const cls = `shadow-${size}`          // shadow-sm silently changed meaning
    const variants = { small: 'rounded' } // 'rounded' silently changed meaning
    
    # Grep for the risky renames across the whole repo:
    grep -rnE "(^|[\"' ])(shadow|rounded|blur|drop-shadow|backdrop-blur)([\"' ]|$)" src/
    ⚠ Heads up: Dynamically constructed class names are the most common source of post-migration visual bugs. The build succeeds and the styles are simply wrong.
  4. Step 4

    Swap the build plugin

    Tailwind no longer ships as a PostCSS plugin under its own name. Which package you need depends on your bundler. If you are on Vite, the dedicated plugin is meaningfully faster than going through PostCSS and is the recommended path. Note that postcss-import and autoprefixer are now handled internally — remove them.

    // PostCSS — postcss.config.mjs
    export default {
      plugins: {
        "@tailwindcss/postcss": {},
      },
    };
    
    // Vite — vite.config.ts (preferred)
    import tailwindcss from "@tailwindcss/vite";
    
    export default defineConfig({
      plugins: [tailwindcss()],
    });
    
    // CLI — the binary moved to its own package
    // v3: npx tailwindcss -i input.css -o output.css
    // v4: npx @tailwindcss/cli -i input.css -o output.css
  5. Step 5

    Replace the @tailwind directives with a single import

    The three @tailwind directives collapse into one standard CSS import. This is a real @import, so it must come before other rules in the file.

    /* v3 */
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
    /* v4 */
    @import "tailwindcss";
  6. Step 6

    Move your theme into CSS with @theme

    Design tokens now live in CSS as custom properties inside @theme, and each one generates the matching utilities. The variable namespace determines what gets generated: --color-* creates bg-*/text-*/border-*, --breakpoint-* creates responsive variants, --font-* creates font-*. Because they are real CSS variables, they are also readable at runtime — which replaces v3's resolveConfig().

    @import "tailwindcss";
    
    @theme {
      --color-brand-100: oklch(0.97 0.02 265);
      --color-brand-500: oklch(0.62 0.19 265);
      --font-display: "Inter", sans-serif;
      --breakpoint-3xl: 120rem;
      --spacing: 0.25rem;
    }
    
    /* Generates: bg-brand-500, text-brand-100, font-display, 3xl:flex, ... */
    
    /* Reading theme values from JS (resolveConfig() is gone in v4): */
    /* const styles = getComputedStyle(document.documentElement);
       const brand = styles.getPropertyValue("--color-brand-500");        */
  7. Step 7

    Keep a JavaScript config only if you must

    If your config has logic the CSS syntax cannot express, @config will load the old file — but treat it as a transitional crutch, not a destination. Three options are simply gone: corePlugins, safelist, and separator. Safelisting has a direct replacement in @source inline().

    /* Load a legacy JS config */
    @import "tailwindcss";
    @config "../../tailwind.config.js";
    
    /* Safelist replacement — corePlugins/safelist/separator are unsupported */
    @source inline("bg-red-500 bg-green-500 bg-blue-500");
    ⚠ Heads up: `corePlugins`, `safelist`, and `separator` are silently ignored under `@config`. If you relied on `safelist` for CMS-driven classes, migrate to `@source inline()` or those styles will vanish from your build.
  8. Step 8

    The renamed scales that silently change your design

    This is the change most likely to ship a subtle regression. Every default-sized shadow, radius, and blur shifted down one step to make room for a new extra-small size. The old class names still exist and still compile — they just render smaller than before. Nothing errors.

    shadow-sm         →  shadow-xs
    shadow            →  shadow-sm
    drop-shadow-sm    →  drop-shadow-xs
    drop-shadow       →  drop-shadow-sm
    blur-sm           →  blur-xs
    blur              →  blur-sm
    backdrop-blur-sm  →  backdrop-blur-xs
    backdrop-blur     →  backdrop-blur-sm
    rounded-sm        →  rounded-xs
    rounded           →  rounded-sm
    ⚠ Heads up: A bare `shadow` or `rounded` left in your markup now renders one size smaller than it did in v3. No warning, no error — just a slightly different design.
  9. Step 9

    Opacity utilities are gone — use the slash syntax

    The paired *-opacity-* utilities were removed in favour of the modifier syntax that v3 already supported. Anywhere you set a colour and its opacity as two classes, collapse them into one.

    <!-- v3 -->
    <div class="bg-black bg-opacity-50 text-white text-opacity-75">
    
    <!-- v4 -->
    <div class="bg-black/50 text-white/75">
    
    <!-- Also removed: -->
    <!-- border-opacity-*  divide-opacity-*  ring-opacity-*  placeholder-opacity-* -->
    <!-- flex-shrink-* → shrink-*   flex-grow-* → grow-* -->
    <!-- overflow-ellipsis → text-ellipsis -->
  10. Step 10

    Default values that changed underneath you

    Several defaults were changed to be less opinionated. Each one is a visual change that the compiler cannot warn you about. If you would rather not audit every usage, the compatibility CSS below restores the v3 behaviour wholesale.

    @layer base {
      /* Borders defaulted to gray-200; they now default to currentColor */
      *, ::after, ::before, ::backdrop, ::file-selector-button {
        border-color: var(--color-gray-200, currentColor);
      }
    
      /* Placeholders were gray-400; now current color at 50% opacity */
      input::placeholder, textarea::placeholder {
        color: var(--color-gray-400);
      }
    
      /* Buttons were cursor-pointer; now cursor-default */
      button:not(:disabled), [role="button"]:not(:disabled) {
        cursor: pointer;
      }
    }
    
    /* Rings went from 3px/blue-500 to 1px/currentColor.
       Either write ring-3 ring-blue-500 explicitly, or: */
    @theme {
      --default-ring-width: 3px;
      --default-ring-color: var(--color-blue-500);
    }
    ⚠ Heads up: `--default-ring-width` and `--default-ring-color` are deprecated escape hatches. Prefer fixing the call sites so you are not carrying compatibility shims forward.
  11. Step 11

    outline-none now means something different

    This one is an accessibility trap. In v3, outline-none removed the visible outline while leaving a transparent one for forced-colors mode. In v4 that behaviour is called outline-hidden, and outline-none genuinely sets outline-style: none.

    <!-- v3 behaviour is now called outline-hidden -->
    <input class="focus:outline-none" />    <!-- v3 -->
    <input class="focus:outline-hidden" />  <!-- v4 equivalent -->
    
    <!-- `outline` also now sets 1px by default, so the width class is enough -->
    <input class="outline outline-2" />     <!-- v3 -->
    <input class="outline-2" />             <!-- v4 -->
    ⚠ Heads up: Leaving `focus:outline-none` in place removes the focus indicator for Windows High Contrast users. Change it to `outline-hidden` unless you truly want no outline at all.
  12. Step 12

    space-y and divide use a different selector

    Both utilities switched from :not([hidden]) ~ :not([hidden]) to :not(:last-child), which is dramatically faster on large lists but behaves differently when children are conditionally hidden. For new markup, flex or grid with gap is the better construct.

    /* v3 */
    .space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
    
    /* v4 — note it now sets margin-bottom on all but the last child */
    .space-y-4 > :not(:last-child) { margin-bottom: 1rem; }
    ⚠ Heads up: If you toggle child visibility with the `hidden` attribute, spacing will now render around the hidden element. Switch those containers to `flex flex-col gap-4`.
  13. Step 13

    Arbitrary value and modifier syntax

    Three syntax changes that produce build-time errors rather than silent breakage — easier to catch, but you need to know the new forms. CSS variables in arbitrary values now use parentheses, commas become underscores, and !important moved to the end of the class.

    <!-- CSS variables: square brackets → parentheses -->
    <div class="bg-[--brand-color]"></div>   <!-- v3 -->
    <div class="bg-(--brand-color)"></div>   <!-- v4 -->
    
    <!-- Commas in arbitrary values become underscores -->
    <div class="grid-cols-[max-content,auto]"></div>  <!-- v3 -->
    <div class="grid-cols-[max-content_auto]"></div>  <!-- v4 -->
    
    <!-- Important modifier moved to the end -->
    <div class="!flex hover:!bg-red-600"></div>       <!-- v3 -->
    <div class="flex! hover:bg-red-600!"></div>       <!-- v4 -->
    
    <!-- Stacked variants now read left to right -->
    <ul class="py-4 first:*:pt-0"></ul>   <!-- v3 -->
    <ul class="py-4 *:first:pt-0"></ul>   <!-- v4 -->
  14. Step 14

    Custom utilities move from @layer to @utility

    Registering your own utilities through @layer utilities no longer makes them variant-aware. The @utility directive replaces it and integrates properly with hover:, md:, and the rest. The container utility also lost its center and padding config options and is now customised the same way.

    /* v3 */
    @layer utilities {
      .tab-4 { tab-size: 4; }
    }
    
    /* v4 */
    @utility tab-4 {
      tab-size: 4;
    }
    
    /* container no longer takes center/padding options — override it directly */
    @utility container {
      margin-inline: auto;
      padding-inline: 2rem;
    }
    
    /* Multi-property component classes still belong in @layer components */
    @layer components {
      .btn { border-radius: 0.5rem; padding: 0.5rem 1rem; }
    }
  15. Step 15

    Using @apply inside component frameworks

    Vue SFCs, Svelte components, and CSS Modules each compile their <style> blocks in isolation, so they have no idea what your theme is. @apply there needs an explicit @reference to your main stylesheet — or, better, just use the CSS variables directly, which skips the compilation step entirely.

    <style>
      /* Tell the isolated style block where the theme lives */
      @reference "../../app.css";
      h1 { @apply text-2xl font-bold text-red-500; }
    </style>
    
    <style>
      /* Simpler and faster — no @reference needed */
      h1 { color: var(--color-red-500); font-size: var(--text-2xl); }
    </style>
    ⚠ Heads up: Tailwind v4 is not compatible with Sass, Less, or Stylus. If your project preprocesses CSS, that layer has to come out before you migrate.
  16. Step 16

    Verify the migration

    Work through this list before merging. The build passing proves very little here — most v4 regressions are visual, and every one of them compiles cleanly.

    # 1. Build succeeds and the CSS bundle is a sane size
    npm run build
    
    # 2. No stale v3 packages left behind
    npm ls tailwindcss postcss-import autoprefixer
    
    # 3. No leftover directives or removed utilities
    grep -rn "@tailwind " src/
    grep -rnE "(bg|text|border|ring|divide|placeholder)-opacity-" src/
    grep -rnE "flex-(shrink|grow)-" src/
    grep -rn "outline-none" src/
    
    # 4. Eyeball the things that changed silently:
    #    - shadows and border radii (one size smaller)
    #    - border colors (currentColor, not gray-200)
    #    - focus rings (1px currentColor, not 3px blue)
    #    - focus visibility in forced-colors mode
    #    - any list using space-y with conditionally hidden children

Feature requests

Sign in to suggest features or vote on existing ones.

No feature requests yet.

Discussion

0 people marked this as worked·Sign in to mark your own.

Sign in to join the discussion.

No comments yet.