Emit the colors as CSS variables, overridable at runtime - #12
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:A plugin cannot reach
@themeeither — handed toaddBaseit is copied into the output verbatim as an at-rule nothing parses, and no utilities come out of it. ThePluginAPIhas no theme-registration hook;addBaseis 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
@propertyregistrations: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:Composites reference the leaves rather than restating their literals, so overriding
--color-light-primarymoves--color-primaryand 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 themea user's@themeoverride lands in, and is not tree-shaken. A first pass declared the composites in:rootand silently broke@themeoverrides, 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 registeredlight-dark()freezes at the root'scolor-schemeand stops following a dark subtree. Verified in a browser.initial-valuemay not containvar()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:
spec-version: 2021spec-version: 2025The 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: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'sneutralanderrorpalettes share a name with Tailwind's and the step numbers only partly overlap, so the two scales interleave under the defaultextend(bg-neutral-50is Material's mid grey,bg-neutral-500is still Tailwind's).colors: replacesetstheme.colorsand drops Tailwind's palette so every name means one thing.blackandwhiteare kept explicitly, as those come from the theme rather than being built into the utilities.Size
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
createThemewas mutating the shareddefaultConfigurationmodule object across callsSourceColorUndefinedErrorwas dead code — a missing source color threwPluginOptionsUndefinedErrordocs/tailwind-plugin-api.mdrecords the plugin API limits behind all of this, with open questions for laterVerification
41 tests pass,
tscclean, example app builds and renders. The browser-level claims —light-dark()freezing under registration,var()rejected ininitial-value, invalid-override fallback, animation propagating through composites, layer ordering, and@themeoverrides 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