tailwindcss icon indicating copy to clipboard operation
tailwindcss copied to clipboard

bg-conic not working in next.js production builds

Open Multiply opened this issue 7 months ago • 14 comments

What version of Tailwind CSS are you using? v4.1.6

What build tool (or framework if it abstracts the build tool) are you using? Next.js 15.3.2

What version of Node.js are you using? v22.11.0

What browser are you using? Chrome

What operating system are you using? macOS

Reproduction URL https://github.com/Multiply/next.js-tailwind-conic-bug (based on npx create-next-app@latest for the initial commit)

Describe your issue In next.js production builds (npm run build -> npm run start) bg-conic's background-image does not work. It works fine in development builds npm run dev.

My initial thoughts would be that it's a @layer issue, but I haven't played too much with it yet, to really debug it further.

Multiply avatar May 12 '25 07:05 Multiply

Debugged the difference with @tailwindcss/cli --minify. The key difference stopping it from working is:

+++ Next.js
--- @tailwindcss/cli
 @property --tw-gradient-from-position {
   syntax:"<length-percentage>";
   inherits:false;
+  initial-value:0
-  initial-value:0%
 }

It seems like some extra CSS minifier strips the percentage sign from the above @property value, causing the gradient to stop working.

You can workaround this by adding the class name from-0% but probably not ideal.

wongjn avatar May 12 '25 07:05 wongjn

  • initial-value:0
  • initial-value:0% It seems like some extra CSS minifier strips the percentage sign from the above @property value, causing the gradient to stop working.

I noticed this initially, and assumed 0 was an accepted value for syntax: "<length-percentage>"; It seems like it does this for other values as well.

Multiply avatar May 12 '25 08:05 Multiply

Hey!

This is happening because of a minifier used in Next.js. As a workaround, you could add this workaround to your next.config.js file:

const nextConfig = {
  webpack(config) {
    // Disable CSS minification
    config.optimization.minimizer = config.optimization.minimizer.filter((fn: any) => {
      return !fn.toString().includes("CssMinimizerPlugin");
    });

    return config;
  },
}
module.exports = nextConfig

Or use ESM / TypeScript, the important part is the webpack config to remove the CSS minifier.

RobinMalfait avatar May 12 '25 09:05 RobinMalfait

Are there any tickets tracking this issue that you know of, @RobinMalfait?

Multiply avatar May 12 '25 11:05 Multiply

@Multiply not yet, I have to create a minimal reproduction and open an issue, but wanted to give you a workaround in the meantime.

RobinMalfait avatar May 12 '25 11:05 RobinMalfait

Following some breadcrumbs, this might have fixed it? https://github.com/cssnano/cssnano/pull/1695

Edit: Seems like nextjs@canary is using [email protected] that doesn't contain the above PR, so maybe upgrading to 7.0.7 could do something.

Multiply avatar May 12 '25 11:05 Multiply

That might fix it, but I also think that Next.js is bundling the dependency instead of relying on it as an actual dependency. They also use cssnano-simple: https://github.com/vercel/next.js/tree/canary/packages/next/src/bundles/cssnano-simple

RobinMalfait avatar May 12 '25 11:05 RobinMalfait

That might fix it, but I also think that Next.js is bundling the dependency instead of relying on it as an actual dependency. They also use cssnano-simple: https://github.com/vercel/next.js/tree/canary/packages/next/src/bundles/cssnano-simple

Yes, that's where I got the above package from, as they include that in there.

Multiply avatar May 12 '25 11:05 Multiply

As a follow-up, I'm confident the PR I referenced earlier does not fix the underlying issue. It's only about <percentage> and quotes around it, it doesn't affect <length-percentage>. (See my comment here: https://github.com/cssnano/cssnano/pull/1695#issuecomment-2872599305)

I'd assume if we just add one more condition there, it'll be solved.

Edit: I've added a PR to solve it here: https://github.com/cssnano/cssnano/pull/1702

Multiply avatar May 12 '25 13:05 Multiply

Opened an upstream issue with a reproduction:

  • Issue: https://github.com/vercel/next.js/issues/79149
  • Reproduction: https://github.com/RobinMalfait/nextjs-incorrect-css-minification

RobinMalfait avatar May 13 '25 10:05 RobinMalfait

Hi,

The primary step is to meticulously compare the generated CSS output for bg-conic in both dev (inspect element styles) and prod (examining the bundled .css files). Look for:

// (Highly simplified pseudo-code)

interface ThemeConfig { theme: { [scale: string]: { [key: string]: string; // e.g., aspectRatio: { video: '16/9' } }; // ... other theme sections }; // ... other config }

// Existing logic (simplified) for arbitrary values like aspect-[16/9] function migrateArbitraryValue(className: string, themeConfig: ThemeConfig): string | null { const match = className.match(/^([a-z-]+)-[(.+)]$/); // e.g., "aspect-[16/9]" if (match) { const utility = match[1]; // "aspect" const arbitraryValue = match[2]; // "16/9" const themeScale = getThemeScaleForUtility(utility, themeConfig); // e.g., themeConfig.theme.aspectRatio

if (themeScale) {
  for (const [key, value] of Object.entries(themeScale)) {
    if (value === arbitraryValue) {
      return `${utility}-${key}`; // "aspect-video"
    }
  }
}

} return null; }

// --- NEW/MODIFIED LOGIC INTRODUCED BY THIS PR --- function migrateBareValue(className: string, themeConfig: ThemeConfig): string | null { // 1. Identify if it's a utility that could have a bare value corresponding to a theme value. // This requires parsing the className, e.g., "aspect-16/9" -> utility="aspect", bareValue="16/9" // It needs a robust way to distinguish "16/9" from a named key like "video". // This might involve checking if "16/9" exists as a key in theme.aspectRatio. If not, it's a bare value.

const parsed = parseBareUtility(className); // Helper to get { utility: "aspect", value: "16/9" } if (!parsed) return null;

const { utility, bareValue } = parsed; const themeScale = getThemeScaleForUtility(utility, themeConfig); // e.g., themeConfig.theme.aspectRatio

if (themeScale) { // 2. Perform a "reverse lookup": find if the bareValue exists as a value in the theme scale. for (const [key, valueInTheme] of Object.entries(themeScale)) { if (valueInTheme === bareValue) { // Found a match: e.g., theme.aspectRatio.video is '16/9' // And our bareValue is '16/9' // So, replace 'aspect-16/9' with 'aspect-video' return ${utility}-${key}; } } } return null; // No named equivalent found for this bare value }

// Main processing function in the upgrade tool function upgradeFileContent(content: string, themeConfig: ThemeConfig): string { let newContent = content; // Find all class attributes/strings // For each class name: // let newClassName = migrateArbitraryValue(className, themeConfig); // if (!newClassName) { // If not handled by arbitrary, try bare value // newClassName = migrateBareValue(className, themeConfig); // } // if (newClassName) { // replace className with newClassName in newContent; // } return newContent; }

// Helper function (conceptual) function getThemeScaleForUtility(utilityName: string, themeConfig: ThemeConfig): object | undefined { // Maps utility prefixes (e.g., "aspect") to theme scales (e.g., themeConfig.theme.aspectRatio) // This mapping would be predefined or derived. if (utilityName === 'aspect') return themeConfig.theme.aspectRatio; // ... other mappings for colors, spacing, etc. return undefined; }

function parseBareUtility(className: string): { utility: string, value: string } | null { // Complex logic to split, e.g., "aspect-16/9" into "aspect" and "16/9", // or "text-red-500" into "text" and "red-500" (if "red-500" is treated as a bare value for colors) // Needs to handle prefixes, potential negative signs, fractions, etc. // For "aspect-16/9", it's relatively simple. const parts = className.split('-'); if (parts.length < 2) return null; // Not enough parts const utility = parts[0]; // The value might be multiple parts joined by '-', e.g. for colors or complex values // For aspect-16/9, the value is "16/9" which contains a slash. // A more robust parser is needed here. const valueCandidate = parts.slice(1).join('-'); // "16/9"

// This is a simplification; actual parsing is more nuanced
// to avoid mistaking `aspect-video` (where `video` is a key) for a bare value.
// The check usually involves seeing if `valueCandidate` is NOT a key in the theme.
return { utility, value: valueCandidate };

}

Good luck!, SUMAN SUHAG

sumansuhag avatar May 13 '25 16:05 sumansuhag

This issue is happening on Nuxt.js production builds as well

SilvioDoMine avatar Jun 07 '25 22:06 SilvioDoMine

Workarounds and Solutions Disable CSS Minification in Next.js You can disable the problematic CSS minifier in your Next.js production build by modifying your next.config.js file:- // next.config.js const nextConfig = { webpack(config) { // Remove the CSS minifier (CssMinimizerPlugin) that breaks conic gradients config.optimization.minimizer = config.optimization.minimizer.filter((fn) => { return !fn.toString().includes("CssMinimizerPlugin"); }); return config; }, }; module.exports = nextConfig; This disables CSS minification, which prevents the percentage sign from being stripped and allows bg-conic to work as expected in production.

This might work.

IsmeetKachhap007 avatar Jun 19 '25 11:06 IsmeetKachhap007

Manually patch the output CSS file (post-build hook)

If you want to keep minification but fix the bug, you can patch the final output file via a custom Node script after build.

scripts/patch-css.js

import fs from 'fs';
import path from 'path';

const cssDir = './.next/static/css';

fs.readdirSync(cssDir).forEach(file => {
  if (file.endsWith('.css')) {
    const filePath = path.join(cssDir, file);
    let content = fs.readFileSync(filePath, 'utf8');

    // Patch initial-value: 0 to 0% only for --tw-gradient-from-position
    content = content.replace(
      /@property\s+--tw-gradient-from-position\s*\{([^}]*?)initial-value:\s*0([^%])/,
      (_, pre, post) => `@property --tw-gradient-from-position {${pre}initial-value: 0%${post}`
    );

    fs.writeFileSync(filePath, content, 'utf8');
    console.log(`✔ Patched ${file}`);
  }
});

Run this after build:

"scripts": {
  "build": "next build && node scripts/patch-css.js"
}

✅ This ensures the CSS is fixed after all minification.

devmuhnnad avatar Jun 24 '25 06:06 devmuhnnad