Skip to main
a head full of memes by
Timo Mämecke
Jump to navigation
· 5 minute read

Upgrading to Tailwind v4.0

On 22 January, 3 days ago at the time of writing, Tailwind v4.0 was released with some major changes. I decided to upgrade today, and the upgrade path was mixed to be honest!

Tailwind provides an automated upgrade tool, which worked okay-ish. One of the major changes is the new CSS-first configuration instead of the old JavaScript-based configuration. Unfortunately, the upgrade tool didn’t migrate the configuration, saying it couldn’t. Maybe because of the typography plugin, maybe because of something else, I don’t know, it didn’t tell me.

/* This is how the new configuration file should look like */
@import "tailwindcss";
@theme {
  --font-display: "Satoshi", "sans-serif";
  --breakpoint-3xl: 1920px;
  --color-avocado-100: oklch(0.99 0 0);
  --color-avocado-200: oklch(0.98 0.04 113.22);
  --color-avocado-300: oklch(0.94 0.11 115.03);
  --color-avocado-400: oklch(0.92 0.19 114.08);
  --color-avocado-500: oklch(0.84 0.18 117.33);
  --color-avocado-600: oklch(0.53 0.12 118.34);
  --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
  --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}

/* This is what the upgrade tool did */
@config "../../tailwind.config.js";
css

But the new CSS-based config is a big part of Tailwind 4, and I wanted to use it. The upgrade tool simply loaded the old config in compatibility mode.

So I had to migrate it manually, but the upgrade guide doesn’t really explain how to migrate the old JavaScript-based configuration. So I had to figure it out for myself, and while some of it was obvious and easy to find, other things weren’t so easy.

Dark mode variants

I use multiple dark mode variants. This is what it looked like in the old configuration:

module.exports = {
  darkMode: [
    'variant',
    [
      '@media (prefers-color-scheme: dark) { &:not(html[data-theme=light] *, [data-theme=light]) }',
      '&:is([data-theme=dark] *, html[data-theme=dark])',
    ],
  ]
}
js

The dark: variant can now be configured with the @custom-variant directive. The Dark mode docs have an example of how to override it with a single rule, but how do I use it with multiple rules like in the old configuration? It took me a while to figure out that the docs use a shorthand syntax, and you can use multiple rules with the long syntax:

/* Example in the docs, uses the shorthand syntax */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

/* This is how it works with multiple selectors */
@custom-variant dark {
  &:where([data-theme='dark'] *, [data-theme='dark']) {
    @slot;
  }

  @media (prefers-color-scheme: dark) {
    &:not(html[data-theme='light'] *, [data-theme='light']) {
      @slot;
    }
  }
}
css

Overriding the gray color palette

Tailwind’s color palette has 5 different gray temperatures, ranging from a cool blueish gray called “Slate” to a warm brownish gray called “Stone”. You usually use only one of these grays. I like to use “Stone” and have aliased it to gray.

const colors = require('tailwindcss/colors')

module.exports = {
  theme: {
    extends: {
      colors: {
        gray: colors.stone,
      }
    }
  }
}
js

How does this aliasing work in the new CSS-based configuration? Well, you have to override all gray shades.

@theme {
  --color-gray-50: var(--color-stone-50);
  --color-gray-100: var(--color-stone-100);
  --color-gray-200: var(--color-stone-200);
  --color-gray-300: var(--color-stone-300);
  --color-gray-400: var(--color-stone-400);
  --color-gray-500: var(--color-stone-500);
  --color-gray-600: var(--color-stone-600);
  --color-gray-700: var(--color-stone-700);
  --color-gray-800: var(--color-stone-800);
  --color-gray-900: var(--color-stone-900);
  --color-gray-950: var(--color-stone-950);
}
css

Custom background images

To create a utility class with a custom background image (for an image file, not a gradient), you could extend the theme like any other utility:

module.exports = {
  theme: {
    extends: {
      backgroundImage: {
        'dark-grain': "url(dark-grain-pattern.svg)",
        'light-grain': "url(light-grain-pattern.svg)"
      }
    }
  }
}
js

The new docs to customize background-image only mention gradients, but not images. So I guessed how it should work based on how it used to work, but I guessed wrong. I also couldn’t figure out if there’s still a way to extend it within the theme, so I’m now simply using a utility.

@theme {
  /* This does not work! */
  --bg-dark-grain: url("dark-grain-pattern.svg"); 
  --bg-light-grain: url("light-grain-pattern.svg"); 
}

/* This works */
@utility bg-dark-grain {
  background-image: url("dark-grain-pattern.svg"); 
}

@utility bg-light-grain {
  background-image: url("light-grain-pattern.svg"); 
}
css

Using arbitrary transition-property values

I have some places where I only want to transition the transform and opacity properties, and I simply used transition-[transform,opacity] to apply these transitions. But after upgrading to v4, these transitions were broken! I couldn’t find anything in the upgrade guide, and the transition-property docs didn’t look any different at first glance.

Until I saw that transition-transform now uses 4 properties: the usual transform, but 3 new custom properties translate, scale and rotate. This is not mentioned as a breaking change, and the upgrade tool didn’t catch it either.

You now have to transition those properties as well:

<!-- v3 -->
<div class="transition-[transform,opacity]" />

<!-- v4 -->
<div class="transition-[transform,translate,scale,rotate,opacity]" />
html

Of course, you don’t need to specify all of these properties if you only need to transition some of them.

Using the Tailwind Typography plugin

Tailwind’s typography plugin is great, and I think you should always use it (or any alternative for good standard text rendering). When I was manually migrating the configuration, I finally got to the typography configuration and was wondering how to migrate it to the new CSS-based configuration. The documentation of the plugin didn’t mention the new config, and the upgrade guide didn’t mention anything either. Until I found a note in the functions and directives docs:

@plugin

Use the @plugin directive to load a legacy JavaScript-based plugin:

@plugin "@tailwindcss/typography";
css

Legacy? Oh no. Let’s hope we get non-legacy plugins in the future.

Anyway, to use the typography plugin, you can use the old configuration and import it into the new configuration.

@config './typography.config.js';

@theme {
  /* ... */
}
css
// typography.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  plugins: [require('@tailwindcss/typography')],
  theme: {
    extend: {
      typography: () => ({
        DEFAULT: {
          css: {
            color: '#333',
            a: {
              color: '#3182ce',
              '&:hover': {
                color: '#2c5282',
              }
            }
            // etc
js

Final thoughts

Although the upgrade path has been a bit of a bumpy ride, I like the overall change in direction that Tailwind has taken. It now not only supports the new features of the platform, it embraces them and shows them to you. It’s much more of an extension of the platform, rather than just a collection of utility classes.