Skip to content

postcss-preset-env polyfills light-dark(), which the docs say we never do #56

Description

@minimaldesign

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

  1. 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.
  2. 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.
  3. 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%)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions