From a2e11b037f031c4cd60d527d6f5f64bcf11fc6d4 Mon Sep 17 00:00:00 2001 From: Claas Date: Fri, 7 Aug 2026 01:06:33 +0200 Subject: [PATCH 1/3] Emit the colors as CSS variables, overridable at runtime 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 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 --- README.md | 147 ++ docs/tailwind-plugin-api.md | 158 ++ index.js | 337 +++- index.test.js | 194 ++- theme test.snapshot.json | 2867 ++++++++++++++++++++++------------- 5 files changed, 2603 insertions(+), 1100 deletions(-) create mode 100644 docs/tailwind-plugin-api.md diff --git a/README.md b/README.md index c0b5c82..189179c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ export default { | `variant` | `monochrome`, `neutral`, `tonal-spot`, `vibrant`, `expressive`, `fidelity`, `content`, `rainbow`, `fruit-salad` | `tonal-spot` | | `spec-version` | `2021`, `2025` | `2021` | | `gamut` | `srgb`, `display-p3` (or `p3`), `rec2020` | `srgb` | +| `colors` | `extend`, `replace` | `extend` | Every option also accepts camelCase (`sourceColor`, `specVersion`) so the same names work in a `tailwind.config.js`. @@ -64,6 +65,7 @@ work in a `tailwind.config.js`. variant: vibrant; spec-version: 2025; gamut: display-p3; + colors: replace; } ``` @@ -128,6 +130,151 @@ Some things worth knowing before turning it on: Browser support is not a concern: `oklch()` has been Baseline since 2023, which is older than the `light-dark()` these colors are already emitted with. +#### `colors` + +Whether the generated colors are added to Tailwind's palette or take its place. + +Material names its palettes for their role, and two of those names — `neutral` and +`error` — are also Tailwind color names. Material's steps go `0, 5, 10, … 95, 98, 99, 100` +and Tailwind's go `50, 100, 200, … 950`, so under the default `extend` the two scales +interleave. Material wins the steps it defines and Tailwind keeps the rest: + +``` +bg-neutral-50 Material tone 50 a mid grey +bg-neutral-500 Tailwind's neutral-500 +``` + +`replace` drops Tailwind's palette entirely, so every color name means exactly one thing: + +```css +@plugin "@claas.dev/material-tailwind" { + source-color: #0c1445; + colors: replace; +} +``` + +This removes `bg-red-500`, `text-sky-300` and every other Tailwind color utility. The +color keywords are built into the utilities rather than read from the theme, so +`bg-transparent`, `bg-current` and `bg-inherit` are unaffected, and `black` and `white` +are kept explicitly because they do come from the palette and are too widely used to drop. + +`extend` stays the default so upgrading does not move anyone's colors. + +# Overriding colors at runtime + +Every color is emitted as a CSS variable in Tailwind's own `--color-*` namespace, so you +can change one at runtime without rebuilding: + +```js +document.documentElement.style.setProperty("--color-light-primary", "#ff0000"); +``` + +### How the variables are wired + +There are two kinds of token. **Leaves** hold a literal color — every per-scheme role and +every palette step: + +```css +--color-light-primary #525a92 +--color-dark-primary #bbc3ff +--color-light-high-contrast-primary #1f275c +--color-primary-40 #525a92 +``` + +**Composites** are built out of leaves with `light-dark()`, and are what the unqualified +utilities use: + +```css +--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 every utility built on it. Override +leaves, not composites. + +### Override in unlayered CSS, not in `@theme` + +A plugin can only write base styles, so these variables land in `@layer base`. A `@theme` +block lands in `@layer theme`, which comes first and therefore loses — an override written +there is silently ignored: + +```css +/* Does nothing. */ +@theme { + --color-light-primary: #ff0000; +} + +/* Works: unlayered CSS beats every layer. */ +:root { + --color-light-primary: #ff0000; +} +``` + +Setting the property from JavaScript works for the same reason. This is a limitation of +the plugin API rather than a choice — see +[docs/tailwind-plugin-api.md](docs/tailwind-plugin-api.md). + +### The leaves are registered with `@property` + +```css +@property --color-light-primary { + syntax: ""; + inherits: true; + initial-value: #525a92; +} +``` + +This buys two things. An override that is not a color falls back to the generated default +instead of poisoning every declaration that reads it — an unregistered custom property +would take the whole `background-color` down with it. And a registered property is +animatable, so a theme change can be transitioned: + +```css +:root { + transition: --color-light-primary 300ms, --color-dark-primary 300ms; +} +``` + +The transition has to be declared on the element where the value changes — usually +`:root` — not on the element being painted. Interpolation propagates through the +`light-dark()` composites to the utilities. + +### Why composites are not registered + +A registered `` property resolves to a single color at the element it is declared +on and inherits as that color. A registered `light-dark()` is therefore frozen at the +root's `color-scheme` and stops following a `color-scheme: dark` subtree. Composites have +to stay unregistered so `light-dark()` resolves where it is used. This also rules out +folding them into an `initial-value`, which may not contain `var()` at all. + +### Roles are not derived from palette steps + +It is tempting to make `--color-light-primary` reference `--color-primary-40`, so that +overriding five palettes would retint everything. It does not hold up. 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. Roles and +palette steps are therefore independent leaves. + +### What this does not do + +Changing the **source color** at runtime is not possible this way. Deriving a scheme from +a source color needs Material's HCT solver, which is JavaScript. CSS variables let you +override individual roles and swap between schemes you generated ahead of time. + +### The cost + +Because a Tailwind plugin cannot register real theme variables, all 817 tokens ship +whether or not a utility uses them — Tailwind's tree shaking does not apply. In the +[example app](example) this took the stylesheet from 3.90 kB to 11.62 kB gzipped. About +1 kB of that is the `@property` registrations; the rest is the lost tree shaking. See +[docs/tailwind-plugin-api.md](docs/tailwind-plugin-api.md) for why. + # How it works The plugin generates colors with [@material/material-color-utilites](https://www.npmjs.com/package/@material/material-color-utilities) and extends the Tailwind CSS theme to make them available for you. Additionally this plugin extends the default theme with various design tokens collected from [material.io](https://material.io) and the [Material 3 Design Kit (Community)](https://www.figma.com/community/file/1035203688168086460). diff --git a/docs/tailwind-plugin-api.md b/docs/tailwind-plugin-api.md new file mode 100644 index 0000000..dbc798e --- /dev/null +++ b/docs/tailwind-plugin-api.md @@ -0,0 +1,158 @@ +# Where the Tailwind CSS plugin API constrains this plugin + +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 and, where the question was about browser behaviour, by measuring in a +browser. Findings are dated to that version — 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. + +## 1. Plugin theme values are inlined, never emitted as variables + +A plugin returning `{ theme: { extend: { colors: { primary: "#525a92" } } } }` produces: + +```css +.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: + +```css +.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`. + +## 2. `@theme` is unreachable from a plugin + +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`: + +```css +@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. + +## 3. No API to register a theme variable + +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. + +## 4. Consequence: no tree shaking + +This is the expensive one. + +Variables that enter through `@theme` are tree-shaken — only the ones actually used are +emitted. Variables written through `addBase` are ordinary base styles, so all of them +ship, every build, whether or not a single utility references them. + +For this plugin that is 817 tokens. Measured on the example app: + +| | raw | gzip | +| --- | --- | --- | +| before (inlined literals) | 17.34 kB | 3.90 kB | +| after (variables) | 131.59 kB | 11.62 kB | + +About 1 kB gzip of the increase is the `@property` registrations. The rest is the lost +tree shaking. There is no way to recover it from inside a plugin: we cannot know which +utilities the user's content will produce, and even if we could, we have no hook that runs +late enough to prune. + +The escape hatch is not a plugin at all — a codegen step that writes a `.css` file with a +real `@theme static { … }` block, which the user `@import`s. 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. + +## 5. `addBase` output lands in `@layer base`, which loses to `@theme` + +Tailwind's layer order is `theme, base, components, utilities`. Our variables are in +`base`; a user's `@theme` block lands in `theme`. Later layers win, so **our definitions +beat a user's `@theme` override** — the opposite of what someone would expect. + +In practice this bites less than it sounds. Runtime overrides via +`element.style.setProperty()` or an unlayered `