Summary
The documented PostCSS config disables two preset-env features by name, but not light-dark-function. So every mCSS project ships a full light-dark() polyfill, for a feature the docs list as used "natively, with no polyfills".
On a real project (Astro, the documented config verbatim) it costs 47.6 KB raw and 3.8 KB gzipped, 23.3% of the stylesheet.
It is not only weight. The polyfill also emits rules the framework's own layer model does not expect, and turning it off does not produce the fallback the docs promise. Both below.
What preset-env emits
For each light-dark(a, b) it writes a numbered scratch property and a space-toggle:
/* source */
--paper: light-dark(#fcfcf9, #141414);
/* output */
--csstools-light-dark-toggle--62: var(--csstools-color-scheme--light) #141414;
--paper: var(--csstools-light-dark-toggle--62, #fcfcf9);
Then it repeats every affected token twice more, gated on support:
| # |
Context |
Purpose |
| 1 |
@layer theme > :root |
polyfill, ungated |
| 2 |
@layer theme > @supports (color: light-dark(red, red)) > :root |
native |
| 3 |
@layer theme > @supports not (color: light-dark(tan, tan)) > :root * |
polyfill, re-applied |
In one project build that is 313 scratch properties and 21 :root * rules.
The :root * rule crosses layers
Entry 3 applies every affected token to every element in the document, inside the layer the declaration came from. Twenty of those land in @layer settings, which is harmless: settings is the lowest layer, so components still win.
One lands in @layer theme, because that is where a project palette goes, and theme sits above components in the mCSS order. In a browser without light-dark(), @layer theme { :root * { --token: … } } therefore beats a component's own override of that token in @layer components, on every element. That is the documented way to set a palette defeating the documented way to override one.
settings.ui.css uses light-dark() 61 times, and those are the interface tokens components are meant to override. Any of them moved into or restated by a theme inherits the problem.
Why it happens
.browserslistrc is the deliberate floor:
defaults and supports css-cascade-layers
That is ~Baseline 2022. light-dark() is Baseline newly available since 2024-05-13. So there is a two-year window of browsers that the browserslist floor admits and light-dark() does not reach, and preset-env is correctly polyfilling for them. The config is doing what it was told; the floor and the feature set disagree.
The blog post's own troubleshooting section names this exact failure mode ("Your browserslist target is too broad"), but the remedy there does not apply, because narrowing the floor to Baseline 2024 also drops browsers the docs currently claim to support.
The catch: disabling it does not give the light palette
start.mdx describes the intended degradation:
light-dark() (mid-2024): colors fall back to the light palette.
That holds when light-dark() is written directly in a property, where an unparsable declaration is dropped and the previous cascade value stands. It does not hold when it is written into a custom property, which is how settings.ui.css declares all 61.
A custom property stores any token sequence, valid or not. The failure is deferred to substitution, and an invalid substitution makes the property unset rather than falling back:
:root { --tok: light-dark(#fcfcf9, #141414); } /* stored fine, even unsupported */
.thing { border-color: var(--tok); } /* invalid at computed-value time */
Verified in a browser using an unknown function in place of light-dark():
- the custom property computes to the literal
nosuchfn(#fcfcf9, #141414)
border-color computes to currentColor, the unset value, not the light half
So in the 2022 to 2024 window, dropping the polyfill gives unset for every property fed by a token, not the light palette: currentColor borders, inherited text color, transparent backgrounds. The docs' fallback story is written for the direct form and does not survive the token indirection the framework is built on.
Options
- Raise
.browserslistrc to a Baseline 2024 floor. Makes the config match the "no polyfills" claim, kills this and any other 2024-feature polyfill in one move, and saves the 23%. Costs the 2022 to 2024 browsers, so start.mdx browser support needs rewriting.
- Add
'light-dark-function': false alongside the two existing exclusions. Same saving, but the fallback paragraph in start.mdx is then wrong and should be corrected to say colors go unset rather than light.
- Keep the polyfill and document it. Then "no polyfills" needs qualifying, and the
:root * layer interaction is worth a note in agents/pitfalls.md.
Happy to send a PR for whichever you prefer.
Environment
- mCSS 1.2.0, vendored from source
postcss-preset-env at stage: 2, config copied verbatim from the docs, cascade-layers and random-function already disabled
- Astro build,
.browserslistrc as shipped
- Measured on a project whose build contains 84
light-dark() uses: 71 from the framework (61 of them in settings.ui.css) and 13 in its own theme
Numbers, same project, single config change:
|
raw |
gzip |
| polyfill on |
204,890 |
30,973 |
| polyfill off |
157,250 |
27,215 |
| saved |
47,640 (23.3%) |
3,758 (12.1%) |
Summary
The documented PostCSS config disables two preset-env features by name, but not
light-dark-function. So every mCSS project ships a fulllight-dark()polyfill, for a feature the docs list as used "natively, with no polyfills".On a real project (Astro, the documented config verbatim) it costs 47.6 KB raw and 3.8 KB gzipped, 23.3% of the stylesheet.
It is not only weight. The polyfill also emits rules the framework's own layer model does not expect, and turning it off does not produce the fallback the docs promise. Both below.
What preset-env emits
For each
light-dark(a, b)it writes a numbered scratch property and a space-toggle:Then it repeats every affected token twice more, gated on support:
@layer theme > :root@layer theme > @supports (color: light-dark(red, red)) > :root@layer theme > @supports not (color: light-dark(tan, tan)) > :root *In one project build that is 313 scratch properties and 21
:root *rules.The
:root *rule crosses layersEntry 3 applies every affected token to every element in the document, inside the layer the declaration came from. Twenty of those land in
@layer settings, which is harmless: settings is the lowest layer, so components still win.One lands in
@layer theme, because that is where a project palette goes, andthemesits abovecomponentsin the mCSS order. In a browser withoutlight-dark(),@layer theme { :root * { --token: … } }therefore beats a component's own override of that token in@layer components, on every element. That is the documented way to set a palette defeating the documented way to override one.settings.ui.cssuseslight-dark()61 times, and those are the interface tokens components are meant to override. Any of them moved into or restated by a theme inherits the problem.Why it happens
.browserslistrcis the deliberate floor:That is ~Baseline 2022.
light-dark()is Baseline newly available since 2024-05-13. So there is a two-year window of browsers that the browserslist floor admits andlight-dark()does not reach, and preset-env is correctly polyfilling for them. The config is doing what it was told; the floor and the feature set disagree.The blog post's own troubleshooting section names this exact failure mode ("Your browserslist target is too broad"), but the remedy there does not apply, because narrowing the floor to Baseline 2024 also drops browsers the docs currently claim to support.
The catch: disabling it does not give the light palette
start.mdxdescribes the intended degradation:That holds when
light-dark()is written directly in a property, where an unparsable declaration is dropped and the previous cascade value stands. It does not hold when it is written into a custom property, which is howsettings.ui.cssdeclares all 61.A custom property stores any token sequence, valid or not. The failure is deferred to substitution, and an invalid substitution makes the property
unsetrather than falling back:Verified in a browser using an unknown function in place of
light-dark():nosuchfn(#fcfcf9, #141414)border-colorcomputes tocurrentColor, the unset value, not the light halfSo in the 2022 to 2024 window, dropping the polyfill gives
unsetfor every property fed by a token, not the light palette:currentColorborders, inherited text color, transparent backgrounds. The docs' fallback story is written for the direct form and does not survive the token indirection the framework is built on.Options
.browserslistrcto a Baseline 2024 floor. Makes the config match the "no polyfills" claim, kills this and any other 2024-feature polyfill in one move, and saves the 23%. Costs the 2022 to 2024 browsers, sostart.mdxbrowser support needs rewriting.'light-dark-function': falsealongside the two existing exclusions. Same saving, but the fallback paragraph instart.mdxis then wrong and should be corrected to say colors go unset rather than light.:root *layer interaction is worth a note inagents/pitfalls.md.Happy to send a PR for whichever you prefer.
Environment
postcss-preset-envatstage: 2, config copied verbatim from the docs,cascade-layersandrandom-functionalready disabled.browserslistrcas shippedlight-dark()uses: 71 from the framework (61 of them insettings.ui.css) and 13 in its own themeNumbers, same project, single config change: