Skip to content

Emit the colors as CSS variables, overridable at runtime - #12

Merged
SantaClaas merged 3 commits into
mainfrom
emit-colors-as-css-variables
Aug 6, 2026
Merged

Emit the colors as CSS variables, overridable at runtime#12
SantaClaas merged 3 commits into
mainfrom
emit-colors-as-css-variables

Conversation

@SantaClaas

@SantaClaas SantaClaas commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Makes every generated color a CSS variable so it can be changed at runtime without a rebuild.

The problem

Theme values a plugin returns are inlined by Tailwind's JS config compat layer, so no --color-* variable was emitted at all:

.bg-primary { background-color: light-dark(#525a92, #bbc3ff); }

A plugin cannot reach @theme either — handed to addBase it is copied into the output verbatim as an at-rule nothing parses, and no utilities come out of it. The PluginAPI has no theme-registration hook; addBase is the only place a plugin can put a declaration.

How the tokens are wired

Leaves hold a literal color — every per-scheme role and every palette step — and are the only thing written out, as @property registrations:

@property --color-light-primary {
  syntax: "<color>";
  inherits: true;
  initial-value: #525a92;
}

This makes an override that is not a color fall back to the generated default instead of poisoning every declaration reading it, and makes the token animatable.

Composites are the light-dark() pairs. They are never declared — they live in the theme value as a fallback:

.bg-primary {
  background-color: var(--color-primary, light-dark(var(--color-light-primary), var(--color-dark-primary)));
}

Composites reference the leaves rather than restating their literals, so overriding --color-light-primary moves --color-primary and everything built on it. Because nothing defines --color-primary, setting it anywhere wins — including from @theme — and a color no utility uses costs nothing.

That last point is the one real lesson here: a plugin should read variables, not declare them. Anything it declares lands in @layer base, which beats the @layer theme a user's @theme override lands in, and is not tree-shaken. A first pass declared the composites in :root and silently broke @theme overrides, which had worked before this branch.

Why composites are not registered

A registered <color> property resolves to a single color at the element it is declared on and inherits as that color, so a registered light-dark() freezes at the root's color-scheme and stops following a dark subtree. Verified in a browser. initial-value may not contain var() either, which rules it out independently.

Roles are not derived from palette steps

Tempting, but a role is exactly a palette tone often enough to look right and not often enough to be correct:

light dark light-high-contrast
spec-version: 2021 53/59 45/59 35/59
spec-version: 2025 12/59 13/59 28/59

The 2025 chroma multipliers and the contrast levels break the correspondence.

Two new options

contrasts — 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 does not use a single one of. They are opt-in now:

contrasts: high medium;   /* or `all`, or comma-separated */

The default level is always generated since the unqualified roles come from it. Breaking: bg-high-contrast-* and friends now need opting in.

colors — Material's neutral and error palettes share a name with Tailwind's and the step numbers only partly overlap, so the two scales interleave under the default extend (bg-neutral-50 is Material's mid grey, bg-neutral-500 is still Tailwind's). colors: replace sets theme.colors and drops Tailwind's palette so every name means one thing. black and white are kept explicitly, as those come from the theme rather than being built into the utilities.

Size

raw gzip
before 17.34 kB 3.90 kB
after 43.38 kB 6.27 kB

Fixed cost is 227 leaf registrations, ~1.83 kB gzip (581 and ~4.20 kB with contrasts: all). That part cannot be tree-shaken — a plugin cannot know which utilities the user's content will produce, and has no hook that runs late enough to prune.

Also

  • createTheme was mutating the shared defaultConfiguration module object across calls
  • SourceColorUndefinedError was dead code — a missing source color threw PluginOptionsUndefinedError
  • docs/tailwind-plugin-api.md records the plugin API limits behind all of this, with open questions for later

Verification

41 tests pass, tsc clean, example app builds and renders. The browser-level claims — light-dark() freezing under registration, var() rejected in initial-value, invalid-override fallback, animation propagating through composites, layer ordering, and @theme overrides working again — were each measured in a browser rather than reasoned about. The snapshot covers theme and variables; snapshotting the theme alone would no longer catch a color regression.

🤖 Generated with Claude Code

SantaClaas and others added 3 commits August 7, 2026 01:06
Theme values a plugin returns are inlined by Tailwind's JS config compat
layer, so no --color-* variable was emitted at all and nothing could be
changed without a rebuild. A plugin cannot reach @theme either: handed to
addBase it is copied out verbatim as an at-rule nothing parses. So the
theme values become var() references and the definitions they point at
are written through addBase, which reproduces what @theme would emit,
minus its tree shaking.

The tokens split in two. Leaves hold a literal color and are registered
with @Property, which makes an override that is not a color fall back to
the generated default instead of poisoning every declaration reading it,
and makes the token animatable. Composites are the light-dark() pairs and
stay unregistered: a registered <color> resolves at the element it is
declared on, so a registered light-dark() freezes at the root's
color-scheme and stops following a dark subtree. They are composed out of
the leaves rather than restating their literals, so overriding
--color-light-primary moves --color-primary and everything built on it.

Roles are deliberately not derived from palette steps. A role is exactly a
palette tone 53 of 59 times under the 2021 spec but only 12 of 59 under
2025, where the chroma multipliers and contrast levels break the
correspondence.

Also adds a colors option. Material's neutral and error palettes share a
name with Tailwind's, and the step numbers only partly overlap, so the two
scales interleave under the default extend. replace sets theme.colors and
drops Tailwind's palette, keeping black and white as those come from the
theme rather than being built into the utilities.

This costs the example app 3.90 kB to 11.62 kB gzipped, as every token now
ships whether or not a utility uses it. About 1 kB of that is @Property.
docs/tailwind-plugin-api.md records the API limits behind all of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the first pass. Declaring the composites in a :root
block lands them in @layer base, which beats the @layer theme a user's
@theme override lands in, so `@theme { --color-primary: red }` silently
stopped working — it did work before the variables change. And every one
of the 817 tokens shipped on every build, which took the example app from
3.90 kB to 11.62 kB gzipped.

Both come from the same mistake: a plugin should read variables, not
declare them. Anything it declares is a value the user cannot override
from where Tailwind documents, and is not tree shaken.

So the composites move into the theme value as a fallback:

  var(--color-primary, light-dark(var(--color-light-primary), …))

Nothing defines --color-primary, so setting it anywhere wins, @theme
included, and a color no utility uses costs nothing. Overriding a leaf
still moves everything composed out of it.

The rest of the size was the contrast levels. Material defines every role
at four of them and the three non-default ones were 531 of 817 tokens,
68% of the output, which the example app does not use one of. They are
opt-in now, listed in whatever separator reads naturally:

  contrasts: high medium;
  contrasts: all;

The default level is always generated, as the unqualified roles come from
it. This removes bg-high-contrast-* and friends unless asked for.

The example app is now 6.27 kB gzipped, and the fixed cost is 227 leaf
registrations at about 1.83 kB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tailwind CSS IntelliSense draws no color square next to any utility this
plugin generates, which is a regression: before the colors became
variables the values were literal hex and light-dark(hex, hex), and the
extension resolves both.

It looks like a separate problem and is not. The extension resolves a
utility's value and then asks the design system for any theme variable in
it, which is why bg-red-500 still works. A plugin cannot put anything into
the design system, so --color-primary is never resolved, and the var()
that survives is replaced with a literal 1 before parsing, leaving nothing
that reads as a color.

Checked against bradlc.vscode-tailwindcss@0.16.0 rather than recalled.

This makes the codegen escape hatch look better than it did: a real
@theme block is exactly what the language server reads, so it would fix
this along with most of the rest of the document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SantaClaas
SantaClaas merged commit 22f3441 into main Aug 6, 2026
2 checks passed
@SantaClaas
SantaClaas deleted the emit-colors-as-css-variables branch August 6, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant