Skip to content

Instantly share code, notes, and snippets.

@alexanderbuhler
Last active September 25, 2025 22:04
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

  • Effectively getting browser support down to Chrome 99 (and basically everything supporting @layer)
  • 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

Hints and caveats

  • Updated for Tailwind v4.1 If you are still using v4.0 or want to support Chrome versions lower than 99 down to 90, try uncommenting some of the other polyfills.
  • Reported to not work with a nuxt setup out of the box. This setup was build around a vite/vue setup.
  • 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 (needs special treatment now, see commented polyfills)
/* 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 {
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}`
})
})
},
postcssPlugin: 'postcss-property-polyfill',
}
}
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 {
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
}
})
},
postcssPlugin: 'postcss-color-mix-var-resolver',
}
}
colorMixVarResolverPlugin.postcss = true
/*
This plugin transforms shorthand rotate/scale/translate into their transform[3d] counterparts
*/
const transformShortcutPlugin = () => {
return {
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)
}
},
postcssPlugin: 'postcss-transform-shortcut',
}
}
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 {
/**
* 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()
}
})
},
postcssPlugin: 'postcss-add-space-for-empty-var-fallback',
}
}
addSpaceForEmptyVarFallback.postcss = true
const config = {
plugins: [
/*
commented out polyfills are kept here for optional chrome >90<99 support should it be needed
*/
// require('@csstools/postcss-cascade-layers'), conflicts with tailwind specificity and some rules would need manual handling
// propertyInjectPlugin(),
// colorMixVarResolverPlugin(),
transformShortcutPlugin(),
addSpaceForEmptyVarFallback(),
require('postcss-media-minmax'),
require('@csstools/postcss-oklab-function'),
require('postcss-nesting'),
// require('@csstools/postcss-color-mix-function'),
// require('autoprefixer'),
],
}
module.exports = 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.

@alexanderbuhler
Copy link
Author

alexanderbuhler commented Apr 28, 2025

@11Firefox11 Thanks for pointing that out. Shouting that out in the description now. Haven't migrated to v4.1 myself, but am planning to do in the near future and update this gist for what's left to handle there. So far, it will break some stuff on v4.1.

@tobiaspickel Also added info about your experience 🤝

@ccthecode
Copy link

@alexanderbuhler have you gotten the chance to update v4.1?

@alexanderbuhler
Copy link
Author

@ccthecode Not yet! Planning to attack that in a couple weeks.

@alexanderbuhler
Copy link
Author

@ccthecode Updated to v4.1 now.

I've removed stuff quite defensively, so there might be polyfills still active in the list which aren't needed anymore as the v4.1 update has fixed a bunch of things (but not quite everything).

@ccthecode
Copy link

@alexanderbuhler Ah, Alas! A great relief! Thank you very much. I'll check it out!

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