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
@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