Keep var() in Source and Ship Static Fallbacks with PostCSS
Keep var() in your source CSS while PostCSS emits static fallbacks at build time. Compare postcss-custom-properties and postcss-preset-env, with a config example and verification steps.
21 Apr 2026, 16:36 UTC

You want to write color: var(--accent) once and reuse it across the stylesheet, but part of your audience still runs browsers that treat var() as invalid and drop the whole declaration. The useful move is not to abandon custom properties in your source CSS — it is to let PostCSS resolve those references into static values at build time, so authors keep modern syntax while the compiled stylesheet carries fallbacks.
CSS custom properties (often called CSS variables) are declarations such as --accent: #0b5fff on :root, referenced elsewhere with var(--accent). Browsers without custom-property support ignore both the declaration and every property that uses var(), which is why a missing fallback can silently remove a color or spacing value.
What the transform actually does
PostCSS parses a stylesheet into an abstract syntax tree (AST) — a structured representation of every rule, declaration, and at-rule — runs plugins over that tree, and then prints CSS again. postcss-custom-properties walks the tree, finds each var() reference, looks up the matching custom-property declaration, and substitutes the literal value. Because the substitution happens before output, your source file keeps var() untouched.
Source maps record which generated lines came from which source lines. Enable them in your runner (the PostCSS CLI's --map flag, or the devtool/source-map setting in Vite or webpack) so browser dev tools can point a compiled fallback rule back to the original source line.
Choosing between the standalone plugin and the preset
Two supported routes cover this decision:
| Option | Scope | Best when |
|---|---|---|
postcss-custom-properties | Custom properties only, with explicit options such as preserve | You want a single, obvious transform and full control over whether original var() rules are kept |
postcss-preset-env | Bundles custom-properties plus other future CSS features, configured by maturity stage | You are adopting several newer syntaxes at once and want one preset instead of a plugin stack |
If custom-property fallbacks are the only gap, the standalone plugin keeps the pipeline easy to reason about. If you are also compiling nesting, logical properties, or other newer syntax, postcss-preset-env avoids stacking plugins by hand.
Worked example: compile fallbacks from var() source
Given this source stylesheet:
:root { --accent: #0b5fff; }
.button { color: var(--accent); }
Configure PostCSS in postcss.config.js at your project root (CommonJS format shown; use the ESM equivalent if your package sets "type": "module"):
// postcss.config.js — run builds from the project root, where package.json lives
module.exports = {
plugins: [
require('postcss-custom-properties')({
preserve: true, // keep original var() rules alongside the static fallbacks
}),
],
};
Install the packages and build with the PostCSS CLI. Run these from the project root; they need no special permissions beyond write access to the output directory. Replace src/styles.css and dist/styles.css with your real paths:
npm install --save-dev postcss postcss-cli postcss-custom-properties
npx postcss src/styles.css -o dist/styles.css --map
In the compiled dist/styles.css, you should find the .button rule carrying the literal value #0b5fff where var(--accent) was written; with preserve: true, the original var() declaration remains alongside it. Verify this yourself by opening the output file and by running grep -n "var(--" dist/styles.css to see exactly which references survived. With preserve: false (the default), the output contains only static values. Plugin option names and defaults change between releases, so confirm the current preserve behavior against the plugin's README before shipping.
Trade-offs and limits to plan around
- Bundle size grows. Static fallbacks duplicate values in the output, so the compiled CSS is larger than shipping custom properties alone. Compare sizes with both
preservesettings. - Runtime values are invisible to the build. A custom property set from JavaScript, an inline style, or changed at runtime cannot be resolved at build time; the emitted fallback is a snapshot. If your theming relies on toggling
--accentat runtime, static-only output (preserve: false) will break that override in older browsers. - Plugin order matters. Run variable resolution after any syntax transform that introduces
var()usage, and before minification (for example cssnano), so the minifier sees resolved values rather than unresolved references. preserve: truesends unusedvar()rules to modern browsers. Harmless functionally, but it adds bytes to browsers that would have used the custom property natively.
A practical rollout
- Check your own audience data (analytics or support tables) to decide whether fallbacks are needed at all; current evergreen browsers support custom properties natively.
- Install the plugin, pin its version in
package.json, and add the config above. - Build and inspect the output: confirm static values replaced
var()references and usegrepto audit what remains. - Enable source maps and confirm in browser dev tools that generated rules map back to source lines.
- Toggle
preserveand compare bundle sizes to pick the default that fits your audience.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.