Notes on the limits hit while making the generated colors overridable at runtime.
Everything here was verified against Tailwind CSS 4.2.4 by compiling with the
compile() API, against Tailwind CSS IntelliSense 0.16.0 by reading what the language
server actually does, and, where the question was about browser behaviour, by measuring in
a browser. Findings are dated to those versions — the plugin API is not versioned
separately.
The short version: a JS plugin cannot participate in Tailwind v4's theme system. It can only hand Tailwind a v3-shaped config object and write base styles. Everything below follows from that, including the editor tooling breaking, which looks like a separate problem and is not.
A plugin returning { theme: { extend: { colors: { primary: "#525a92" } } } } produces:
.bg-primary { background-color: #525a92; }No --color-primary is emitted anywhere. Tailwind's JS-config compat layer registers
plugin theme values as inline, so they are substituted into utilities directly.
Contrast a color that came from CSS @theme, in the same stylesheet:
.bg-red-500 { background-color: var(--color-red-500); }Same namespace, same theme, different mechanism — purely because of where the value entered. This is the root limitation; the rest are consequences of working around it.
Worked around by making every theme value a var(--color-…) reference and defining
the variables ourselves through addBase.
The obvious fix is for the plugin to emit an @theme block. It cannot. Passing one to
addBase copies it into the output verbatim, nested inside @layer base:
@layer base {
@theme static {
--color-probe: #bbc3ff;
}
}Tailwind never parses it, no utilities are generated from it, and browsers ignore the
unknown at-rule. @theme, @theme static, @theme inline and @theme default all
behave this way. @theme is a CSS-level directive processed while parsing the stylesheet,
and plugins run after that.
This also means the modifiers Tailwind documents for exactly our situation are
unavailable: static (always emit) and inline (for variables that reference other
variables) are things we have to hand-roll.
The PluginAPI type in Tailwind 4.2.4 exposes:
addBase addUtilities addComponents addVariant matchUtilities
There is no addTheme, no registerTheme, nothing that reaches the theme system the way
CSS @theme does. addBase is the only place a plugin can put a declaration.
This is the expensive one.
Variables that enter through @theme are tree-shaken — only the ones actually used are
emitted. Anything written through addBase is an ordinary base style, so all of it ships,
every build, whether or not a utility references it. We cannot know which utilities the
user's content will produce, and even if we could there is no hook that runs late enough
to prune.
Only what a plugin writes has this problem. Values that live inside the theme, and so inside the utilities, are still tree-shaken by usage. That is what pushed the composites into fallbacks (§5) and left only the leaf registrations as fixed cost.
Two things were done about the size:
- Composites moved into the utilities. 236 tokens that now cost nothing when unused.
- Contrast levels became opt-in. Material defines every role at four contrast levels, and the three non-default ones were 531 of 817 tokens — 68% of the output — which the example app did not use a single one of.
Measured on the example app:
| raw | gzip | |
|---|---|---|
| before (inlined literals) | 17.34 kB | 3.90 kB |
| variables, all four contrasts, composites declared | 131.59 kB | 11.62 kB |
| variables, default contrast, composites as fallbacks | 43.38 kB | 6.27 kB |
The fixed part is now 227 registrations, about 1.83 kB gzip (581 and 4.20 kB with
contrasts: all).
The escape hatch for the rest is not a plugin at all — a codegen step that writes a .css
file with a real @theme static { … } block, which the user @imports. That gets genuine
theme registration, tree shaking, and @theme inline, at the cost of a build step and
giving up @plugin configuration. Worth revisiting if the size becomes a problem again.
Tailwind's layer order is theme, base, components, utilities. Anything a plugin
declares lands in base; a user's @theme block lands in theme. Later layers win, so
a plugin declaration silently beats the user's own override — the opposite of what anyone
would expect.
This bit us. A first version declared the composites in a :root block and made
@theme { --color-primary: … } stop working, which it had done before the change.
The fix was to stop declaring anything. Composites are fallbacks inside the theme value instead:
.bg-primary {
background-color: var(--color-primary, light-dark(var(--color-light-primary), …));
}Nothing defines --color-primary, so setting it anywhere wins, @theme included. It also
costs nothing when no utility uses the color, which is as close to tree shaking as a
plugin gets.
@property registrations are unaffected either way — registration is not a cascading
declaration, so being nested in a layer does not matter. That was verified rather than
assumed.
The general lesson: a plugin should read variables, not declare them. Every value it declares is a value the user cannot override from the place Tailwind documents.
Tailwind CSS IntelliSense draws a colored square next to a color utility in the editor. For every color this plugin generates, that square is now gone.
It is the same root cause as §1–§3 rather than a separate problem, and the mechanism is
worth writing down because it is not obvious. Checked against
bradlc.vscode-tailwindcss@0.16.0:
- The extension resolves a utility's value and then asks the design system for any
theme variable in it —
designSystem.resolveThemeValue(name, true). That is whybg-red-500, which compiles tovar(--color-red-500), still gets a swatch: the design system knows that variable. - Our variables are registered with
@propertyin base styles. The design system has never heard of them, soresolveThemeValuereturns nothing and thevar()survives. - Before parsing a color, the extension replaces any
var(…)with the literal1. An unresolved variable therefore becomes an unparseable value, and no swatch is drawn. - The regex it uses for that is
/var\([^)]+\)/, which stops at the first). Our composite fallback nests variables inside alight-dark(), so it is not merely unresolved but mangled.
The part that stings is that this is a regression, not a pre-existing gap. The
extension does understand light-dark() — it rewrites light-dark(a, b) to a and
swatches the light value. So before the colors became variables, every utility here had a
working swatch: the per-scheme roles were literal hex, and the composites were
light-dark(#525a92, #bbc3ff). Both resolved. Trading that away was not a considered
decision at the time.
Nothing in the plugin API can fix it. The extension is asking the design system a
reasonable question; we simply cannot put anything into the design system (§3). The
codegen escape hatch in §4 would fix this too, and for the same reason it fixes the
others — a real @theme block is exactly what resolveThemeValue reads.
plugin.withOptions takes two functions: one returning the config, one receiving the
PluginAPI. Because the theme values and the variable definitions are two halves of the
same computation, both need the generated colors, and neither can hand anything to the
other. Solving a Material theme is not cheap — eight schemes, each fitted to a gamut — so
the result is memoised per distinct set of options.
Not a serious limitation, but the API shape does force the caching.
Setting theme.colors rather than theme.extend.colors does work from a plugin and
cleanly replaces Tailwind's palette, which is how the colors: replace option is built.
But it is a whole-namespace switch — there is no way to say "drop Tailwind's neutral
scale but keep everything else", which is all that is actually needed to resolve the name
collision. Users who want the collision gone must drop bg-red-500 and every other
Tailwind color with it.
Worth noting: transparent, current and inherit are built into the utilities and
survive replacement, but black and white come from theme.colors and disappear. The
plugin re-adds those explicitly.
These shaped the design just as much and are recorded here so the reasoning is in one place. All measured in a browser.
- A registered
<color>property freezeslight-dark(). It computes to a resolved color at the element it is declared on, so it stops following acolor-scheme: darksubtree. Verified: a registered composite returned the same color in both acolor-scheme: lightand acolor-scheme: darksubtree, while an unregistered one switched correctly. This is why composites are not registered. initial-valuemay not containvar(). It must be computationally independent; a rule that violates this is dropped entirely. This independently rules out registering the composites.- Registration makes invalid overrides safe. An invalid value on a registered property
falls back to
initial-value; on an unregistered one it takes the whole declaration down (rgba(0, 0, 0, 0)). - Registered properties are animatable, and interpolation propagates through an
unregistered
light-dark()composite to the utilities reading it. Thetransitionmust be declared where the value changes, not where it is painted.
- Is the codegen approach from §4 worth offering alongside the plugin? It removes §1, §2,
§4, §5 and §6 at once, which is most of this document. The cost is a build step and
giving up
@pluginconfiguration. - The palettes are 109 of the remaining 227 registrations and are arguably a lower-level tool than the roles. Should they be opt-in the way the contrast levels now are?
- Could the leaves become fallbacks too, the way the composites did, and drop the fixed
cost to nothing? It would mean giving up
@property, and with it the invalid-override fallback and animatable theme changes. That is the real trade: a few kB against those two properties. - Does a later Tailwind version expose anything closer to theme registration for plugins? Worth re-checking §3 on upgrade; the whole design would change if it did.