Tailwind v4 in Astro Without PostCSS Plugins
Tailwind 4 rewrote the engine and the configuration model. It is a Vite plugin rather than a PostCSS plugin, its configuration lives in CSS rather than tailwind.config.js, and it detects sources automatically. For Astro sites, that means the @astrojs/tailwind integration and the PostCSS pipeline it set up are no longer needed: Astro already runs Vite, and Tailwind plugs straight into it.
This guide migrates a 900-page Astro 5 documentation and marketing site from Tailwind 3.4 with @astrojs/tailwind to Tailwind 4 with @tailwindcss/vite, converts the JavaScript config to CSS, keeps dark mode working with the site's data-attribute toggle, and measures the output and build time before and after. It belongs to CSS and JavaScript Asset Optimization.
Prerequisites
- Astro 5 (Astro 4.x also works with the Vite plugin).
- A clean git tree and the current build's CSS output saved for comparison.
- Browsers targeted by Tailwind 4: it relies on cascade layers,
@propertyandcolor-mix(), which need Safari 16.4+, Chrome 111+ and Firefox 128+. Check analytics first; on this site, older browsers were 0.4% of visits.
Step 1: Swap the Integration for the Vite Plugin
npm uninstall @astrojs/tailwind autoprefixer postcss
npm install tailwindcss@4 @tailwindcss/vite@4
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: { plugins: [tailwindcss()] },
});
Remove postcss.config.cjs if Tailwind and autoprefixer were its only plugins — Tailwind 4 handles vendor prefixes through Lightning CSS internally.
Step 2: Import Tailwind in CSS
The integration had injected Tailwind's base styles automatically. Now a global stylesheet imports it, and the base layout imports that stylesheet:
/* src/styles/global.css */
@import 'tailwindcss';
---
import '../styles/global.css';
---
Step 3: Move the Config Into CSS
The old tailwind.config.js had content paths, a theme extension, one plugin and dark mode set to a class strategy. The official upgrade tool converts most of it:
npx @tailwindcss/upgrade
It rewrote templates for renamed utilities and produced a CSS config. After tidying, the result:
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@source '../content/external';
@source not '../data';
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
--font-sans: 'Inter Variable', system-ui, sans-serif;
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-500: oklch(0.58 0.15 250);
--color-brand-700: oklch(0.45 0.14 250);
--breakpoint-3xl: 112rem;
}
@pluginloads the typography plugin, which works with Tailwind 4.@sourceadds content outside automatic detection (a gitignored external content folder) and excludes data files that would otherwise produce accidental utilities, as covered in Keeping Tailwind CSS Output Small on Static Sites.@custom-variant darkmakesdark:utilities follow the site'sdata-themeattribute instead of the OS preference, matching its existing toggle.@themedefines tokens as CSS variables, which also makes them available to plain CSS and inline SVG styles asvar(--color-brand-500).
Step 4: Fix the Renamed and Changed Utilities
The upgrade tool handled most renames, but the visual diff of 40 key pages found four changes it could not:
| Change in Tailwind 4 | Effect on this site | Fix |
|---|---|---|
Default border colour is currentColor (was gray-200) | Bare border turned dark | Add border-gray-200 where needed, or set a base style |
ring defaults to 1 px (was 3 px) | Focus rings looked thin | Use ring-3 explicitly |
| Placeholder colour uses current text colour at 50% | Placeholders darker | Set placeholder colour in base |
space-x/y selector changed | Spacing off on items with hidden siblings | Switch to gap with flex or grid |
A base layer restored the old border and placeholder defaults for the whole site in five lines, which was quicker than editing hundreds of elements:
@layer base {
*, ::before, ::after { border-color: var(--color-gray-200, currentColor); }
input::placeholder, textarea::placeholder { color: var(--color-gray-400); }
}
Step 5: Verify Output and Classes
Two checks before merging. A visual comparison with Playwright screenshots of 40 pages in both themes caught the four default changes above. And the class check from Fixing Missing Tailwind Classes in Production Builds, which compares classes used in the built HTML with selectors in the built CSS, confirmed nothing was missing — automatic source detection had found everything the old content paths listed, plus the external folder once the @source was added.
Using Theme Tokens Outside Tailwind
A side benefit of the CSS-first theme is that every token is a real CSS custom property. Before the migration, the site duplicated its brand colours in three places: tailwind.config.js, a tokens.css file for hand-written component styles, and a JSON file read by the Open Graph image generator. Now the @theme block is the single source. Component styles use var(--color-brand-500) directly, scoped Astro styles do the same, and the OG image script reads the compiled CSS variables from the built stylesheet with a small regex. Inline SVG diagrams can reference the same variables, which made their dark-mode treatment simpler.
There is one caveat: tokens defined in @theme are emitted as variables whether utilities use them or not. Large palettes that exist only for documentation of the design system can be declared with @theme reference in a separate file imported where needed, so they are available to utilities without adding every variable to the shared stylesheet.
Monorepos and Shared Packages
The site lives in a monorepo with a shared UI package. Under Tailwind 3 the package had its own tailwind.config.js preset, imported by each app. In Tailwind 4 the package exports a CSS file with its @theme and @source directives, and each app's global stylesheet imports it after tailwindcss. Sources declared in the package's CSS are resolved relative to that file, so the package can point Tailwind at its own components without each app knowing its folder layout. That removed a class of bug where an app's content paths forgot a newly added package folder.
Measured Impact
| Measure | Tailwind 3.4 + integration | Tailwind 4 + Vite plugin | Tool |
|---|---|---|---|
| CSS step in production build | 3.8 s | 0.4 s | Build log timings |
| Full production build | 61 s | 57 s | hyperfine, 5 runs |
| Dev: class change to browser | ~300 ms | ~40 ms | Browser performance panel |
| CSS output, brotli | 12.4 KB | 11.2 KB | brotli -c | wc -c |
| Config files | 3 | 1 CSS file | Repository |
| Dependencies removed | — | 3 (integration, postcss, autoprefixer) | package.json |
The full build improved by only 4 seconds because CSS generation was a small part of it; the dev server change was what developers noticed.
Pitfalls & Rollback
- Keeping the old integration alongside the Vite plugin. Remove it; running both generates CSS twice.
- Skipping the visual diff. Default border, ring and placeholder changes are not errors.
- Forgetting the dark variant. Without
@custom-variant,dark:follows the OS, not the toggle. - Dropping support for older browsers unknowingly. Check analytics against Tailwind 4's browser targets.
- Rollback: the migration is one branch; reverting it restores the integration, PostCSS config and JavaScript config together.
Conclusion
Tailwind 4 in Astro is simpler than Tailwind 3 was: one Vite plugin, one CSS file holding the theme, sources and variants, and no PostCSS. The migration took half a day on a 900-page site, most of it spent on a visual comparison that caught four changed defaults, and it made the CSS step nine times faster and class edits in the dev server near-instant.
FAQ
Do I still need the @astrojs/tailwind integration with Tailwind 4?
No. Tailwind 4 ships a Vite plugin, @tailwindcss/vite, which Astro can use directly through its vite.plugins setting. The older Astro integration was built around Tailwind 3 and PostCSS. Removing it and adding the Vite plugin is the recommended setup for Tailwind 4 in Astro.
Where does tailwind.config.js go in Tailwind 4?
Configuration moves into CSS. Theme values are defined with the theme directive as CSS variables, sources with the source directive, plugins with the plugin directive and custom variants with the custom-variant directive. A JavaScript config can still be loaded with the config directive during a migration, but the CSS-first form is simpler.
How is dark mode configured in Tailwind 4?
By default dark variants follow prefers-color-scheme. For a toggle that sets a class or data attribute on the html element, define a custom variant in CSS, for example custom-variant dark with a selector for data-theme dark, so dark utilities apply when that attribute is present.
Is Tailwind 4 faster than Tailwind 3 in Astro builds?
In this guide, the CSS step of the production build went from about 3.8 seconds to 0.4 seconds, and the dev server reflected class changes in about 40 milliseconds instead of 300. The overall build time fell by about 4 seconds on a 900-page site, because CSS generation was only a small part of it.
Related
- Parent: CSS and JavaScript Asset Optimization — CSS and JS weight on static sites.
- Keeping Tailwind CSS Output Small on Static Sites — sources and safelists after the move.
- Fixing Missing Tailwind Classes in Production Builds — the class check used here.
- Accessible Dark Mode Toggles for Static Sites — the toggle the dark variant follows.
- Purging Unused CSS with Tailwind and Lightning CSS — Lightning CSS, which Tailwind 4 uses internally.