D
P
0
← All articles Baca dalam Bahasa Indonesia

Tailwind v4 Build-Time Pitfalls

Tailwind 4 Breakpoints Not Working on Older Safari After Moving to Astro? The Minifier Puts `(width>=40rem)` Back

· · 7 min read
Tailwind 4 Breakpoints Not Working on Older Safari After Moving to Astro? The Minifier Puts `(width>=40rem)` Back

The Tailwind version on my portfolio site did not move at all when I migrated the site from Next.js to Astro. The Astro project pins Tailwind 4.2.1 and Astro 7.3.1, and every version pinned there matches what the live Next build had resolved. The compiler was identical, yet the built CSS was not: the migration silently raised the browser support floor to Safari 16.4.

The damage was not cosmetic. The commit message for the fix records ten breakpoint blocks that fail entirely on Safari 16.3 and older, and what breaks is the layout, not the colors. This was one of six defects found and patched during the migration's parity stage, and one of the three I noted as most worth remembering. The patch landed on 8 September 2026, before the domain moved over to the Astro build.

Tailwind 4 writes breakpoints in range syntax

In Tailwind 4.2.1, breakpoint variants are built from the --breakpoint namespace in the theme, and each variant is wrapped in an @media rule that uses range syntax, in the form (width >= value). The default theme sets sm to 40rem, md to 48rem, lg to 64rem, xl to 80rem and 2xl to 96rem. A class like lg:grid-cols-2 ends up looking roughly like the first block below, while older browsers need the second:

/* simplified: Tailwind 4 output for lg:grid-cols-2 */
@media (width >= 64rem) {
  .lg\:grid-cols-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
 
/* the equivalent older form */
@media (min-width: 64rem) {
  .lg\:grid-cols-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

Safari only learned range syntax in version 16.4. An older browser does not read (width >= 64rem) as a slightly wrong condition but as one it cannot parse at all, so the whole block is treated as not matching and every rule inside it is skipped. That is what failing entirely means here. In a mobile first stylesheet like Tailwind's output, unprefixed classes apply at every width, while classes with a breakpoint prefix only live inside those blocks. If the blocks never match, even a wide screen only gets the rules meant for the smallest one.

Colors take a similar route. Tailwind 4.2.1 turns an alpha value into a percentage and mixes the color with transparent through color-mix(in oklab, ...), unless the alpha is 100%. The Next build happened to lower both the range syntax and oklab(). The Astro build left both alone: before the fix, its homepage CSS contained 16 instances of range syntax and 21 of oklab or oklch.

Part one: old targets for the transformer

Astro builds with Vite, and Tailwind on this site comes in through the @tailwindcss/vite plugin in vite.plugins. So the place to lower the syntax is that same vite key: set css.transformer to 'lightningcss', then fill css.lightningcss.targets with older browsers, namely Safari and iOS Safari 15 plus Chrome, Firefox and Edge 100. The versions are written like (15 << 16) because lightningcss reads a browser version as a single number. Vite encodes it the same way when it prepares targets for lightningcss: the major number shifted 16 bits and the minor number 8 bits, so 15 << 16 means 15.0.

The first attempt stopped halfway. The colors came down, but the media queries did not, and they only came down once the minifier was split off. The fix ended up with two parts, and it was the second part, the minifier, that made the first attempt fail.

The default minifier uses its own targets

The commit message sums the problem up in one sentence: the lightningcss transformer with old browser targets lowers the syntax, but the default minifier raises it again because it uses its own targets. Where those targets come from can be traced in the installed Astro 7.3.1 and Vite 8.2.2 code. The excerpts below are trimmed to the relevant lines:

// astro 7.3.1, the build config it assembles for Vite (trimmed)
build: {
  target: "esnext",
  cssMinify: viteConfig.build?.minify == null ? true : !!viteConfig.build?.minify,
  ...viteConfig.build,
},
 
// vite 8.2.2 (trimmed)
cssTarget: merged.cssTarget ?? merged.target,
 
// inside minifyCSS
if (config.build.cssMinify === "esbuild") {
  // esbuild path
}
(await importLightningCSS()).transform({
  ...config.css.lightningcss,
  targets: convertTargets(config.build.cssTarget),
});
 
// inside convertTargets
if (entry === "esnext") continue;
const result = Object.keys(targets).length > 0 ? targets : void 0;

The chain goes like this. Astro writes target: "esnext" into the Vite build config it assembles, both at the top level and in the client environment. As long as cssTarget is not set, Vite uses that build.target as cssTarget. As long as cssMinify is not exactly "esbuild", minifyCSS calls lightningcss by spreading my css.lightningcss first and then overwriting targets with the result of convertTargets(config.build.cssTarget). And convertTargets skips "esnext", so no target is left and the value it returns is undefined.

So once the transformer is set to lightningcss and minification is left at its default, the CSS goes through lightningcss twice. At the transformer stage, compileLightningCSS spreads css.lightningcss into bundleAsync, so my Safari 15 targets apply and the syntax comes down. At the minify stage, those same targets are overwritten with undefined. The minifier is no longer told which browsers it has to serve, and according to the commit message, the syntax that was just lowered gets raised again.

Seven lines of config

Part two is a single line, build: { cssMinify: 'esbuild' } inside the vite key. Astro fills cssMinify with true when build.minify is not set, and it spreads my vite.build after that, so my value wins. With cssMinify set to exactly "esbuild", minifyCSS takes the esbuild path and lightningcss is no longer called with empty targets. The final config, trimmed to the relevant part:

// astro.config.mjs (trimmed)
import tailwindcss from '@tailwindcss/vite';
 
export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
    css: {
      transformer: 'lightningcss',
      lightningcss: {
        targets: { safari: (15 << 16), chrome: (100 << 16), firefox: (100 << 16), edge: (100 << 16), ios_saf: (15 << 16) },
      },
    },
    build: { cssMinify: 'esbuild' },
  },
});

The fix commit only changed that one file, adding seven lines, and it added no dependencies at all because lightningcss was already in the tree as part of Vite. The lockfile at that commit records Vite 8.2.2 under Astro, with lightningcss ^1.33.0 in its dependencies.

What was measured on the homepage CSS

Measured on the homepage CSS, range syntax went from 16 to 0, and oklab and oklch went from 21 to 0. At the same time, min-width went from 3 to 28, max-width was recorded at 25, and hex fallbacks went from 96 to 145. The size went from 76,843 to 77,989 bytes. The difference is 77,989 minus 76,843, which is 1,146 bytes or about 1.1 KB, roughly 1.5 percent of the original size.

Because the change touched the CSS of all 509 pages at once, it was verified in three layers before being pushed, and the commit message records the results. Of the 408 selectors present in both versions, 400 had identical property lists, and the other eight differed because Lightning CSS added something: -webkit-backdrop-filter, which Safari actually needs, -webkit-appearance, and sets of color fallbacks. The number of @media blocks stayed at 13 against 13, and no rule was lost. In a real browser at a width of 1440px, the breakpoints applied: lg:grid-cols-2 produced two 720px columns, exactly half of 1440, and colors with alpha resolved to the right values.

What is not proven yet

There is a limit I want to state plainly. My notes do not mention a test on a real Safari 16.3 or older, neither for the breakage nor for the fix, and the browser used in the 1440px test is not recorded by name or version. What is proven is the CSS output and its behavior in a modern browser: range syntax is gone from the homepage CSS, the number of @media blocks did not change, and the breakpoints still apply. That the layout holds up on a real old Safari is still a conclusion drawn from the CSS, not an observation.

The browser floor follows the pipeline, not the Tailwind version

This bug would never have turned up in a comparison of package versions, because the versions were identical. What changed was what the build did around Tailwind: the Next build happened to lower the syntax, the Astro build did not, and when I asked for it to come down, the default minifier raised it back up. If the colors have already come down but width>= is still in the built CSS, the pattern matches my first attempt: the transformer is doing its job, and the minifier is the thing to suspect. Counting width>= and oklab in the built CSS before and after a build change is a cheap way to see whether the browser floor moved with it.