Skip to content

Instantly share code, notes, and snippets.

@Eptagone
Last active July 15, 2025 09:56
Show Gist options
  • Save Eptagone/a18be9adabaf8ecc54d1c4e6337c95b2 to your computer and use it in GitHub Desktop.
Save Eptagone/a18be9adabaf8ecc54d1c4e6337c95b2 to your computer and use it in GitHub Desktop.
TailwindCSS v4 Polyfill with LightningCSS

TailwindCSS v4 Polyfill with LightningCSS

This is a custom polyfill created with LightningCSS to use TailwindCSS V4 with older browsers.

This solution is not perfect yet and still have some issues.

What does this do?

This polyfill provides custom lightningcss transformers to do the following:

  • Replace all @property rules with css variables.
  • Fix all oklch colors being incorrectly detected as functions so lightningcss can process them. See #809
  • Replace all color variables inside color-mix functions with the actual color so lightningcss can transpile it as described in docs. (The transpilation still fails. Waiting a solution in #943)

Usage Example (Astro)

import solidJs from "@astrojs/solid-js";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
import browserslist from "browserslist";
import { browserslistToTargets } from "lightningcss";
import TailwindPolyfillVisitor from "./tailwind.polyfill";

export default defineConfig({
    // ...
    vite: {
        css: {
            transformer: "lightningcss",
            lightningcss: {
                targets: browserslistToTargets(browserslist(">= 0.01%")), // You can change this according to your needs.
                visitor: TailwindPolyfillVisitor, // The polyfill visitor goes here
            },
        },
        plugins: [tailwindcss()],
    },
});
import {
composeVisitors,
type CustomAtRules, type Function, type ParsedComponent, type ReturnedDeclaration, type ReturnedRule, type TokenOrValue, type Visitor,
} from "lightningcss";
function defineVisitor(visitor: Visitor<CustomAtRules> | (() => Visitor<CustomAtRules>)): Visitor<CustomAtRules> {
return typeof visitor === "function" ? visitor() : visitor;
}
function transformFunctionIntoColor(tokenOrValue: TokenOrValue & { type: "function"; value: Function }): TokenOrValue {
// lightness, chroma, hue
let [l, c, h, alpha] = tokenOrValue.value.arguments
.filter((arg): arg is TokenOrValue & { value: { type: "number"; value: number } } => arg.type === "token" && arg.value.type === "number")
.map(arg => arg.value.value);
l ??= 0;
c ??= 0;
h ??= 0;
alpha ??= 1;
const oklchColor: TokenOrValue = {
type: "color",
value: {
type: "oklch",
l, c, h, alpha,
},
};
return oklchColor;
}
/**
* Fix oklch colors which are detected as functions instead of colors.
*/
const FixOklchColorsVisitor = defineVisitor({
Declaration(declaration): ReturnedDeclaration | ReturnedDeclaration[] | void {
let needsUpdate = false;
if (declaration.property === "custom") {
for (let index = 0; index < declaration.value.value.length; index++) {
const tokenOrValue = declaration.value.value[index];
if (tokenOrValue?.type === "function" && tokenOrValue.value.name === "oklch") {
declaration.value.value[index] = transformFunctionIntoColor(tokenOrValue);
needsUpdate = true;
}
}
}
if (needsUpdate) {
return declaration;
}
},
});
/**
* Replaces all \@property rules with css variables.
*/
const ReplacePropertyRulesVisitor = defineVisitor(() => {
function transformComponentIntoTokensOrValues(component: ParsedComponent): TokenOrValue[] {
switch (component.type) {
case "color":
return [component];
case "length":
if (component.value.type !== "value") {
throw new Error(`Cannot map component of type: ${component.type}.\nValue: ${JSON.stringify(component, undefined, 2)}`);
}
return [{
type: "length",
value: component.value.value,
}];
case "length-percentage":
if (component.value.type !== "percentage") {
throw new Error(`Cannot map component of type: ${component.type}.\nValue: ${JSON.stringify(component, undefined, 2)}`);
}
return [{
type: "token",
value: component.value,
}];
case "token-list":
return component.value;
case "percentage":
return [{
type: "token",
value: {
type: "percentage",
value: component.value,
},
}];
}
throw new Error(`Unexpected component type: ${component.type}.\nValue: ${JSON.stringify(component, undefined, 2)}`);
}
let legacyCssVariables: Record<string, TokenOrValue[]> = {};
return {
StyleSheet(stylesheet) {
const propertyRules = stylesheet.rules.filter(rule => rule.type === "property");
for (const rule of propertyRules) {
if (rule.value.initialValue) {
legacyCssVariables[rule.value.name] = transformComponentIntoTokensOrValues(rule.value.initialValue);
}
}
},
Rule(rule): ReturnedRule | ReturnedRule[] | void {
if (rule.type === "property") {
return [];
}
if (rule.type === "style") {
const selectors = rule.value.selectors.flatMap(selector => selector);
for (const selector of selectors) {
if (selector.type === "pseudo-class" && selector.kind === "root") {
for (const [name, value] of Object.entries(legacyCssVariables)) {
rule.value.declarations.declarations.push({
property: "custom",
value: { name, value },
});
}
legacyCssVariables = {};
return rule;
}
}
}
},
};
});
/**
* Replaces all var(--color-*) with css variables in each color-mix function.
*/
const ReplaceColorMixVariablesVisitor = defineVisitor(() => {
const colorVariables: Record<string, TokenOrValue> = {};
return {
Declaration(declaration): ReturnedDeclaration | ReturnedDeclaration[] | void {
if (declaration.property === "custom") {
if (declaration.value.name.startsWith("--color-")) {
for (let index = 0; index < declaration.value.value.length; index++) {
const tokenOrValue = declaration.value.value[index];
if (tokenOrValue?.type === "color") {
colorVariables[declaration.value.name] = tokenOrValue;
}
if (tokenOrValue?.type === "function" && tokenOrValue.value.name === "oklch") {
colorVariables[declaration.value.name] = transformFunctionIntoColor(tokenOrValue);
}
}
}
}
},
Function(fun): TokenOrValue | TokenOrValue[] | void {
let needsUpdate = false;
if (fun.name === "color-mix") {
for (let index = 0; index < fun.arguments.length; index++) {
const arg = fun.arguments[index];
if (arg?.type === "var") {
const value = colorVariables[arg.value.name.ident];
if (value) {
needsUpdate = true;
// Replace the argument with the color value.
fun.arguments[index] = value;
// If the next argument is a percentage, add a white-space between them.
const nextArg = fun.arguments[index + 1];
if (nextArg?.type === "token" && nextArg.value.type === "percentage") {
fun.arguments.splice(index + 1, 0, { type: "token", value: { type: "white-space", value: " " } });
}
}
}
}
}
if (needsUpdate) {
return {
type: "function",
value: fun,
};
}
},
};
});
/**
* Custom polyfill for TailwindCSS v4.
*/
const TailwindPolyfillVisitor: Visitor<CustomAtRules> = composeVisitors([
FixOklchColorsVisitor,
ReplacePropertyRulesVisitor,
ReplaceColorMixVariablesVisitor,
]);
export default TailwindPolyfillVisitor;
@nekename
Copy link

Thanks a bunch

@nekename
Copy link

Actually, bizarrely, my issues are solved just by adding the transformer: "lightningcss", part, without the polyfill script at all! (as well as a separate fix for the @layer directives not being supported)

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