Skip to content

Instantly share code, notes, and snippets.

@alexanderbuhler
Last active April 24, 2025 15:06
Show Gist options
  • Save alexanderbuhler/2386befd7b6b3be3695667cb5cb5e709 to your computer and use it in GitHub Desktop.
Save alexanderbuhler/2386befd7b6b3be3695667cb5cb5e709 to your computer and use it in GitHub Desktop.
Tailwind v4 polyfill / browser compatibility configuration

This gist may be your full solution or just the starting point for making your Tailwind v4 projects backwards compatible.

What it does

  • Convert @property rules into good old CSS vars
  • Pre-compute oklab() functions
  • Pre-compute color-mix() functions (+ replace CSS vars inside them beforehand)
  • Remove advanced instructions (colorspace) from gradients
  • Provide support for nested CSS (used by dark mode or custom variants with &)
  • Transform translate, scale, rotate properties to their transform: ... notation
  • Add whitespace to var fallbacks var(--var,) > var(--var, ) to help older browsers understand
  • Transform @media queries from using width >= X to old min-width: X notation
  • Autoprefix stuff
  • Add @layer cascade layers polyfill

Hints and caveats

  • Always use via stop in gradients, as a blank position will leave the calculated rule invalid for some older browsers like Safari
  • Independent transform values (scale-2 translate-x-1/2) won't work together, you will need to create a combined class using e.g. @apply translate-0.5 scale-50 rotate-2;
  • To use computed values like alpha with dynamic (non-tailwind/default) color vars that are not available at CSS build time you will need to render those vars as triplets (e.g. --color-var: 125, 55, 128) that you will then pass with the arbitrary tailwind notation: bg-[rgba(var(--color-var),0.5)]

Tested with

  • iOS Safari 16.1
  • iOS Safari 15.5
  • Chrome 99
  • Chrome 90
/* eslint-disable @typescript-eslint/no-require-imports */
/** @type {import('postcss-load-config').Config} */
const postcss = require('postcss')
const valueParser = require('postcss-value-parser')
/*
This plugin polyfills @property definitions with regular CSS variables
Additionally, it removes `in <colorspace>` after `to left` or `to right` gradient args for older browsers
*/
const propertyInjectPlugin = () => {
return {
postcssPlugin: 'postcss-property-polyfill',
Once(root) {
const fallbackRules = []
// 1. Collect initial-value props from @property at-rules
root.walkAtRules('property', (rule) => {
const declarations = {}
let varName = null
rule.walkDecls((decl) => {
if (decl.prop === 'initial-value') {
varName = rule.params.trim()
declarations[varName] = decl.value
}
})
if (varName) {
fallbackRules.push(`${varName}: ${declarations[varName]};`)
}
})
// 2. Inject fallback variables if any exist
if (fallbackRules.length > 0) {
// check for paint() because its browser support aligns with @property at-rule
const fallbackCSS = `@supports not (background: paint(something)) {
:root { ${fallbackRules.join(' ')} }
}`
const sourceFile = root.source?.input?.file || root.source?.input?.from
const fallbackAst = postcss.parse(fallbackCSS, { from: sourceFile })
// Insert after last @import (or prepend if none found)
let lastImportIndex = -1
root.nodes.forEach((node, i) => {
if (node.type === 'atrule' && node.name === 'import') {
lastImportIndex = i
}
})
if (lastImportIndex === -1) {
root.prepend(fallbackAst)
}
else {
root.insertAfter(root.nodes[lastImportIndex], fallbackAst)
}
}
// 3. Remove `in <colorspace>` after `to left` or `to right`, e.g. "to right in oklab" -> "to right"
root.walkDecls((decl) => {
if (!decl.value) return
decl.value = decl.value.replaceAll(/\bto\s+(left|right)\s+in\s+[\w-]+/g, (_, direction) => {
return `to ${direction}`
})
})
},
}
}
propertyInjectPlugin.postcss = true
/*
This plugin resolves/calculates CSS variables within color-mix() functions so they can be calculated using postcss-color-mix-function
Exception: dynamic values like currentColor
*/
const colorMixVarResolverPlugin = () => {
return {
postcssPlugin: 'postcss-color-mix-var-resolver',
Once(root) {
const cssVariables = {}
// 1. Collect all CSS variable definitions from tailwind
root.walkRules((rule) => {
if (!rule.selectors) return
const isRootOrHost = rule.selectors.some(
sel => sel.includes(':root') || sel.includes(':host'),
)
if (isRootOrHost) {
// Collect all --var declarations in this rule
rule.walkDecls((decl) => {
if (decl.prop.startsWith('--')) {
cssVariables[decl.prop] = decl.value.trim()
}
})
}
})
// 2. Parse each declaration's value and replace var(...) in color-mix(...)
root.walkDecls((decl) => {
const originalValue = decl.value
if (!originalValue || !originalValue.includes('color-mix(')) return
const parsed = valueParser(originalValue)
let modified = false
parsed.walk((node) => {
if (node.type === 'function' && node.value === 'color-mix') {
node.nodes.forEach((childNode) => {
if (childNode.type === 'function' && childNode.value === 'var' && childNode.nodes.length > 0) {
const varName = childNode.nodes[0]?.value
if (!varName) return
const resolvedVarName = cssVariables[varName] === undefined ? 'black' : cssVariables[varName] // fall back to black if var is undefined
// add whitespace because it might just be a part of a color notation e.g. #fff 10%
const resolved = `${resolvedVarName} ` || `var(${varName})`
childNode.type = 'word'
childNode.value = resolved
childNode.nodes = []
modified = true
}
})
}
})
if (modified) {
const newValue = parsed.toString()
decl.value = newValue
}
})
},
}
}
colorMixVarResolverPlugin.postcss = true
/*
This plugin transforms shorthand rotate/scale/translate into their transform[3d] counterparts
*/
const transformShortcutPlugin = () => {
return {
postcssPlugin: 'postcss-transform-shortcut',
Once(root) {
const defaults = {
rotate: [0, 0, 1, '0deg'],
scale: [1, 1, 1],
translate: [0, 0, 0],
}
const fallbackAtRule = postcss.atRule({
name: 'supports',
params: 'not (translate: 0)', // or e.g. 'not (translate: 1px)'
})
root.walkRules((rule) => {
let hasTransformShorthand = false
const transformFunctions = []
rule.walkDecls((decl) => {
if (/^(rotate|scale|translate)$/.test(decl.prop)) {
hasTransformShorthand = true
const newValues = [...defaults[decl.prop]]
// add whitespaces for minified vars
const value = decl.value.replaceAll(/\)\s*var\(/g, ') var(')
const userValues = postcss.list.space(value)
// special case: rotate w/ single angle only
if (decl.prop === 'rotate' && userValues.length === 1) {
newValues.splice(-1, 1, ...userValues)
}
else {
// for scale/translate, or rotate with multiple params
newValues.splice(0, userValues.length, ...userValues)
}
// e.g. "translate3d(10px,20px,0)"
transformFunctions.push(`${decl.prop}3d(${newValues.join(',')})`)
}
})
// Process rotate/scale/translate in this rule:
if (hasTransformShorthand && transformFunctions.length > 0) {
const fallbackRule = postcss.rule({ selector: rule.selector })
fallbackRule.append({
prop: 'transform',
value: transformFunctions.join(' '),
})
fallbackAtRule.append(fallbackRule)
}
})
if (fallbackAtRule.nodes && fallbackAtRule.nodes.length > 0) {
root.append(fallbackAtRule)
}
},
}
}
transformShortcutPlugin.postcss = true
/**
* PostCSS plugin to transform empty fallback values from `var(--foo,)`,
* turning them into `var(--foo, )`. Older browsers need this.
*/
const addSpaceForEmptyVarFallback = () => {
return {
postcssPlugin: 'postcss-add-space-for-empty-var-fallback',
/**
* We do our edits in `OnceExit`, meaning we process each decl after
* the AST is fully built and won't get re-visited or re-triggered
* in the same pass.
*/
OnceExit(root) {
root.walkDecls((decl) => {
if (!decl.value || !decl.value.includes('var(')) {
return
}
const parsed = valueParser(decl.value)
let changed = false
parsed.walk((node) => {
// Only consider var(...) function calls
if (node.type === 'function' && node.value === 'var') {
// Look for the `div` node with value "," that separates property & fallback
const commaIndex = node.nodes.findIndex(
n => n.type === 'div' && n.value === ',',
)
// If no comma is found, no fallback segment
if (commaIndex === -1) return
// Gather any fallback text
const fallbackNodes = node.nodes.slice(commaIndex + 1)
const fallbackText = fallbackNodes.map(n => n.value).join('').trim()
// If there's no fallback text => `var(--something,)` => we insert a space
if (fallbackText === '') {
const commaNode = node.nodes[commaIndex]
// If the comma node is literally "," with no space, change it to ", "
if (commaNode.value === ',') {
commaNode.value = ', '
changed = true
}
}
}
})
if (changed) {
decl.value = parsed.toString()
}
})
},
}
}
addSpaceForEmptyVarFallback.postcss = true
const config = {
plugins: [
require('@csstools/postcss-cascade-layers'),
propertyInjectPlugin(),
colorMixVarResolverPlugin(),
transformShortcutPlugin(),
addSpaceForEmptyVarFallback(),
require('postcss-media-minmax'),
require('@csstools/postcss-oklab-function'),
require('@csstools/postcss-color-mix-function'),
require('postcss-nesting'),
require('autoprefixer'),
],
}
module.exports = config
@busy-dog
Copy link

busy-dog commented Apr 3, 2025

Impressive!

@dantaylor
Copy link

dantaylor commented Apr 3, 2025

Hello @alexanderbuhler , I wanted to thank you for this, It really helped me a lot as a good starting point to bring back support to a few older browsers that we are targeting with our app. Once I am done adapting it to my needs I will share back so other people can hopefully benefit from iterating efforts.

@alexanderbuhler
Copy link
Author

@busy-dog πŸ™Œ
@dantaylor Glad it was of some help, mate. Feel free to post any issues you're running into.

πŸ†• Tailwind v4.1 includes some additions for browser compatibility that I'll be checking out in a bit and see what's still left to patch.

@tobiaspickel
Copy link

I tried to get it to run in next 15 but I do get this error:

   Creating an optimized production build ...
Error: An unknown PostCSS plugin was provided ([object Object]).
Read more: https://nextjs.org/docs/messages/postcss-shape
Failed to compile.

./app/globals.css.webpack[javascript/auto]!=!./node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[2]!./node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[3]!./app/globals.css
Error: Malformed PostCSS Configuration
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:178:41
    at Array.forEach (<anonymous>)
    at getPostCssPlugins (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:141:13)
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/index.js:125:36
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js:52:40
    at async Span.traceAsyncFn (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/trace/trace.js:157:20)

Import trace for requested module:
./app/globals.css.webpack[javascript/auto]!=!./node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[2]!./node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[3]!./app/globals.css
./app/globals.css

./app/globals.css
Error: Malformed PostCSS Configuration
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:178:41
    at Array.forEach (<anonymous>)
    at getPostCssPlugins (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:141:13)
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/index.js:125:36
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js:52:40
    at async Span.traceAsyncFn (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/trace/trace.js:157:20)
    at tryRunOrWebpackError (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:316142)
    at __webpack_require_module__ (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:131548)
    at __nested_webpack_require_161494__ (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:130983)
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:131840
    at symbolIterator (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/neo-async/async.js:1:14444)
    at done (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/neo-async/async.js:1:14824)
    at Hook.eval [as callAsync] (eval at create (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:14:9224), <anonymous>:15:1)
    at Hook.CALL_ASYNC_DELEGATE [as _callAsync] (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:14:6378)
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:130703
    at symbolIterator (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/neo-async/async.js:1:14402)
-- inner error --
Error: Malformed PostCSS Configuration
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:178:41
    at Array.forEach (<anonymous>)
    at getPostCssPlugins (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/plugins.js:141:13)
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/config/blocks/css/index.js:125:36
    at async /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js:52:40
    at async Span.traceAsyncFn (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/trace/trace.js:157:20)
    at Object.<anonymous> (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[2]!/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[3]!/Users/tobiaspickel/projects/next-tailwind-4-browser-support/app/globals.css:1:7)
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:962742
    at Hook.eval [as call] (eval at create (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:14:9002), <anonymous>:7:1)
    at Hook.CALL_DELEGATE [as _call] (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:14:6272)
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:131581
    at tryRunOrWebpackError (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:316096)
    at __webpack_require_module__ (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:131548)
    at __nested_webpack_require_161494__ (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:130983)
    at /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/webpack/bundle5.js:29:131840
    at symbolIterator (/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/compiled/neo-async/async.js:1:14444)

Generated code for /Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[2]!/Users/tobiaspickel/projects/next-tailwind-4-browser-support/node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[13].oneOf[10].use[3]!/Users/tobiaspickel/projects/next-tailwind-4-browser-support/app/globals.css

Import trace for requested module:
./app/globals.css

I do have a test repo here do yo have any idea?
I am in the unfortunate situation that I still need to support very old safari versions.
As tailwind 3 was very forgiving with old browsers the v4 update was quite an unpleasant surprise πŸ˜“.

@alexanderbuhler
Copy link
Author

@tobiaspickel Haven't worked with ESM style postcss config yet, there may be some dragons hiding there. What strikes my eye is that "@tailwindcss/postcss" has to go first in the plugin list for sure.

I'd recommend making sure it works with the bare tailwind config and working your way to the complete setup through adding plugins one by one, maybe finding the "faulty" one.

@tobiaspickel
Copy link

Thanks for the speedy reply.
Looks like it is all the custom plugins

propertyInjectPlugin(),
colorMixVarResolverPlugin(),
transformShortcutPlugin(),
addSpaceForEmptyVarFallback(),

I'll dig a bit deeper and post any updates in case I can figure it out.
Kind regards from Altona πŸ‘‹

@tobiaspickel
Copy link

This works in a vite based project.
So it is not esm but something next specific.

//@ts-check
import postcss from "postcss";
import valueParser from "postcss-value-parser";
import tailwind from "@tailwindcss/postcss";
import cascadeLayers from "@csstools/postcss-cascade-layers";
import mediaMinMax from "postcss-media-minmax";
import oklabFunction from "@csstools/postcss-oklab-function";
import colorMixFunction from "@csstools/postcss-color-mix-function";
import nesting from "postcss-nesting";
import autoprefixer from "autoprefixer";

/*
    This plugin polyfills @property definitions with regular CSS variables
    Additionally, it removes `in <colorspace>` after `to left` or `to right` gradient args for older browsers
*/
const propertyInjectPlugin = () => {
  return {
    postcssPlugin: "postcss-property-polyfill",
    Once(root) {
      const fallbackRules = [];

      // 1. Collect initial-value props from @property at-rules
      root.walkAtRules("property", (rule) => {
        const declarations = {};
        let varName = null;

        rule.walkDecls((decl) => {
          if (decl.prop === "initial-value") {
            varName = rule.params.trim();
            declarations[varName] = decl.value;
          }
        });

        if (varName) {
          fallbackRules.push(`${varName}: ${declarations[varName]};`);
        }
      });

      // 2. Inject fallback variables if any exist
      if (fallbackRules.length > 0) {
        // check for paint() because its browser support aligns with @property at-rule
        const fallbackCSS = `@supports not (background: paint(something)) {
                    :root { ${fallbackRules.join(" ")} }
                }`;

        const sourceFile = root.source?.input?.file || root.source?.input?.from;
        const fallbackAst = postcss.parse(fallbackCSS, { from: sourceFile });

        // Insert after last @import (or prepend if none found)
        let lastImportIndex = -1;
        root.nodes.forEach((node, i) => {
          if (node.type === "atrule" && node.name === "import") {
            lastImportIndex = i;
          }
        });

        if (lastImportIndex === -1) {
          root.prepend(fallbackAst);
        } else {
          root.insertAfter(root.nodes[lastImportIndex], fallbackAst);
        }
      }

      // 3. Remove `in <colorspace>` after `to left` or `to right`, e.g. "to right in oklab" -> "to right"
      root.walkDecls((decl) => {
        if (!decl.value) return;

        decl.value = decl.value.replaceAll(
          /\bto\s+(left|right)\s+in\s+[\w-]+/g,
          (_, direction) => {
            return `to ${direction}`;
          },
        );
      });
    },
  };
};

propertyInjectPlugin.postcss = true;

/*
    This plugin resolves/calculates CSS variables within color-mix() functions so they can be calculated using postcss-color-mix-function
    Exception: dynamic values like currentColor
*/
const colorMixVarResolverPlugin = () => {
  return {
    postcssPlugin: "postcss-color-mix-var-resolver",

    Once(root) {
      const cssVariables = {};

      // 1. Collect all CSS variable definitions from tailwind
      root.walkRules((rule) => {
        if (!rule.selectors) return;

        const isRootOrHost = rule.selectors.some(
          (sel) => sel.includes(":root") || sel.includes(":host"),
        );

        if (isRootOrHost) {
          // Collect all --var declarations in this rule
          rule.walkDecls((decl) => {
            if (decl.prop.startsWith("--")) {
              cssVariables[decl.prop] = decl.value.trim();
            }
          });
        }
      });

      // 2. Parse each declaration's value and replace var(...) in color-mix(...)
      root.walkDecls((decl) => {
        const originalValue = decl.value;
        if (!originalValue || !originalValue.includes("color-mix(")) return;

        const parsed = valueParser(originalValue);
        let modified = false;

        parsed.walk((node) => {
          if (node.type === "function" && node.value === "color-mix") {
            node.nodes.forEach((childNode) => {
              if (
                childNode.type === "function" &&
                childNode.value === "var" &&
                childNode.nodes.length > 0
              ) {
                const varName = childNode.nodes[0]?.value;
                if (!varName) return;

                const resolvedVarName =
                  cssVariables[varName] === undefined
                    ? "black"
                    : cssVariables[varName]; // fall back to black if var is undefined
                // add whitespace because it might just be a part of a color notation e.g. #fff 10%
                const resolved = `${resolvedVarName} ` || `var(${varName})`;

                childNode.type = "word";
                childNode.value = resolved;
                childNode.nodes = [];
                modified = true;
              }
            });
          }
        });

        if (modified) {
          const newValue = parsed.toString();
          decl.value = newValue;
        }
      });
    },
  };
};

colorMixVarResolverPlugin.postcss = true;

/*
    This plugin transforms shorthand rotate/scale/translate into their transform[3d] counterparts
*/
const transformShortcutPlugin = () => {
  return {
    postcssPlugin: "postcss-transform-shortcut",

    Once(root) {
      const defaults = {
        rotate: [0, 0, 1, "0deg"],
        scale: [1, 1, 1],
        translate: [0, 0, 0],
      };

      const fallbackAtRule = postcss.atRule({
        name: "supports",
        params: "not (translate: 0)", // or e.g. 'not (translate: 1px)'
      });

      root.walkRules((rule) => {
        let hasTransformShorthand = false;
        const transformFunctions = [];

        rule.walkDecls((decl) => {
          if (/^(rotate|scale|translate)$/.test(decl.prop)) {
            hasTransformShorthand = true;

            const newValues = [...defaults[decl.prop]];
            // add whitespaces for minified vars
            const value = decl.value.replaceAll(/\)\s*var\(/g, ") var(");
            const userValues = postcss.list.space(value);

            // special case: rotate w/ single angle only
            if (decl.prop === "rotate" && userValues.length === 1) {
              newValues.splice(-1, 1, ...userValues);
            } else {
              // for scale/translate, or rotate with multiple params
              newValues.splice(0, userValues.length, ...userValues);
            }

            // e.g. "translate3d(10px,20px,0)"
            transformFunctions.push(`${decl.prop}3d(${newValues.join(",")})`);
          }
        });

        // Process rotate/scale/translate in this rule:
        if (hasTransformShorthand && transformFunctions.length > 0) {
          const fallbackRule = postcss.rule({ selector: rule.selector });

          fallbackRule.append({
            prop: "transform",
            value: transformFunctions.join(" "),
          });

          fallbackAtRule.append(fallbackRule);
        }
      });

      if (fallbackAtRule.nodes && fallbackAtRule.nodes.length > 0) {
        root.append(fallbackAtRule);
      }
    },
  };
};

transformShortcutPlugin.postcss = true;

/**
 * PostCSS plugin to transform empty fallback values from `var(--foo,)`,
 * turning them into `var(--foo, )`. Older browsers need this.
 */
const addSpaceForEmptyVarFallback = () => {
  return {
    postcssPlugin: "postcss-add-space-for-empty-var-fallback",

    /**
     * We do our edits in `OnceExit`, meaning we process each decl after
     * the AST is fully built and won't get re-visited or re-triggered
     * in the same pass.
     */
    OnceExit(root) {
      root.walkDecls((decl) => {
        if (!decl.value || !decl.value.includes("var(")) {
          return;
        }

        const parsed = valueParser(decl.value);
        let changed = false;

        parsed.walk((node) => {
          // Only consider var(...) function calls
          if (node.type === "function" && node.value === "var") {
            // Look for the `div` node with value "," that separates property & fallback
            const commaIndex = node.nodes.findIndex(
              (n) => n.type === "div" && n.value === ",",
            );

            // If no comma is found, no fallback segment
            if (commaIndex === -1) return;

            // Gather any fallback text
            const fallbackNodes = node.nodes.slice(commaIndex + 1);
            const fallbackText = fallbackNodes
              .map((n) => n.value)
              .join("")
              .trim();

            // If there's no fallback text => `var(--something,)` => we insert a space
            if (fallbackText === "") {
              const commaNode = node.nodes[commaIndex];
              // If the comma node is literally "," with no space, change it to ", "
              if (commaNode.value === ",") {
                commaNode.value = ", ";
                changed = true;
              }
            }
          }
        });

        if (changed) {
          decl.value = parsed.toString();
        }
      });
    },
  };
};

addSpaceForEmptyVarFallback.postcss = true;

const config = {
  plugins: [
    tailwind(),
    cascadeLayers(),
    propertyInjectPlugin(),
    colorMixVarResolverPlugin(),
    transformShortcutPlugin(),
    addSpaceForEmptyVarFallback(),
    mediaMinMax(),
    oklabFunction(),
    colorMixFunction(),
    nesting(),
    autoprefixer(),
  ],
};

export default config;

@11Firefox11
Copy link

11Firefox11 commented Apr 24, 2025

Tailwind 4 has @property fallback handling built-in, so no need for a plugin for that.
EDIT: It has color-mix-function handling too.
By handling I mean that it puts @supports around it. If you want to replace them fully then I think you should still use the postcss plugins.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment