Upgrading to Tailwind v4

I just updated this very blog to Tailwind v4—here’s what I had to change!

CSS layers

I have base styles in my global.css file to set various defaults

For example, I added underlines on anchors, overriding preflight

a {
	text-decoration: underline;
	transition:
		color 0.2s ease,
		border-bottom-color 0.2s ease;
}

In my nav, I applied a Tailwind class to remove the underline, since I used borders to distinguish active links

A navbar with white underlines under active links and orange under active links

After upgrading to Tailwind v4, the Tailwind class was overwritten by the rule in global.css

This is because Tailwind v4 uses native CSS Layers, and in this case, styles in global.css come after the base layer

The @utilities layer comes later than @base in the cascade, and therefore styles from @utilities override rules in @base no matter the specificity

@layer theme, base, components, utilities;
@import './theme.css' layer(theme);
@import './preflight.css' layer(base);
@import './utilities.css' layer(utilities);

This was a simple enough fix, just put base styles in the @base layer!

@layer base {
	a {
		text-decoration: underline;
		transition:
			color 0.2s ease,
			border-bottom-color 0.2s ease;
	}
}

Theme variables

This is my favorite part of the new syntax. The @theme directive both adds CSS variables and utility classes, so I only need to update one spot!

Here’s what I had before

theme: {
		extend: {
			fontFamily: {
				sans: ['Arial', 'sans-serif'],
			},
			colors: {
				background: 'rgb(var(--background))',
				foreground: 'rgb(var(--foreground))',
				accent: 'rgb(var(--accent))',
				'background-secondary': 'rgb(var(--background-secondary))',
				'foreground-secondary': 'rgb(var(--foreground-secondary))',
			},
		},
	},
:root {
	--accent: 255 152 0;
	--foreground: 241 230 216;
	--background: 0 0 19;
	--background-secondary: 48 42 79;
	--foreground-secondary: 147 147 147;
}

And here’s what I have now!

@theme {
	--color-background: rgb(0, 0, 19);
	--color-foreground: rgb(241, 230, 216);
	--color-accent: rgb(255, 152, 0);
	--color-background-secondary: rgb(48, 42, 79);
	--color-foreground-secondary: rgb(147, 147, 147);
	--font-sans: 'Arial', 'sans-serif';
}

It’s not that different, but the convenience is nice

Compatibility

@config and @plugin can load legacy configs and plugins. This made migration much nicer because I could do it incrementally!

I use @tailwindcss/typography to make this blog look beautiful—if the @plugin directive didn’t exist, I’dn’t’ve updated!

@plugin "@tailwindcss/typography";

It seems the analog to v3 plugins are just CSS files now. I wonder how many projects will use the new DSL over good old JavaScript

Custom Variants

I could have stopped there, but I wanted to get rid of my config file completely. Here’s how I converted a custom variant that targets inline code blocks

function ({ addVariant }) {
  addVariant(
    'prose-inline-code',
    '&.prose :where(:not(pre)>code):not(:where([class~="not-prose"] *))',
  );
},
@custom-variant prose-inline-code (&.prose :where(:not(pre)>code):not(:where([class~="not-prose"] *)));

If you have no clue what that CSS selector is saying, don’t worry, I got you! It matches elements

  • with an ancestor with the prose class
  • code tags without a pre tag as a direct parent
  • without an ancestor marked as not prose

A friend generated font size utility classes with the following


const pxToRem = (px:number, base = 16) => {
  return px / base
}

const generateFontSize = () => {
  const min = 12
  const max = 100
  const fontSize = {}

  for (let i = min; i <= max; i += 2) {
    fontSize[i] = pxToRem(i) + "rem"
  }

  return fontSize
}

It could be replaced by the following

@utility text-* {
	font-size: calc(--value(integer) * calc(1rem / 16));
}

Of course, this isn’t exact since it allows for arbitrary sizes, but they weren’t interested in migrating anyways ha

You could create 44 CSS variables to match it, though it’s probably not worth it, especially since the Tailwind team has done so well with backwards compatibility

@astrojs/tailwind deprecation

@astrojs/tailwind was deprecated in favor of @tailwindcss/vite

A Vite plugin means the module graph can be used for content detection instead of providing glob patterns1

You can disable automatic detection2, but for me, it just works™!

Rust is a must

The core engine was rewritten in Rust, with one dependency—Lightning CSS, which I think is super cool!

It uses cssparser and selectors from Mozilla, which are used in Firefox, which means there’s browser parity with the foundations3!

I recommend reading through all the minifications Lightning CSS can apply! It’s absolutely delightful to see what it can inline and omit. It’s pretty quick to scan too 😀

For the record, I have not noticed any compile time or bundle size decreases with my tiny blog. That’s not to discount any advances—I was just overly optimistic about lowering my bundle size 😂

AI Sucks at helping with new code

I wonder how long it will be until AI can help with the new version’s DSL. I didn’t expect it to know what was going on, but it was worse than I thought. Copilot using GPT 4o kept hallucinating errors and pasting multiple copies of my CSS file since it had no clue what was going on

Luckily, I am smarter than an LLM and could read the docs (though apparently weak willed, since I asked AI before reading the docs completely)

I asked Phind 70B after the fact, and it knew that Tailwind v4 existed and cited the docs, which is a great start! Unfortunately it got confused about directives and decided to convert all my colors to oklch

Long story short, and needless to say, AI isn’t taking our jobs anytime soon


Check out my PR to upgrade to Tailwind v4 and my follow up PR to remove a !important

Let me know if you had any migration woes or have any opinions about the new DSL—I’m interested to hear what y’all think!

Footnotes

  1. Zero-configuration content detection

  2. Disabling automatic detection

  3. Lightning CSS