Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ 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` |
| `contrasts` | any of `default`, `reduced`, `medium`, `high`, or `all` | `default` |

Every option also accepts camelCase (`sourceColor`, `specVersion`) so the same names
work in a `tailwind.config.js`.
Expand All @@ -64,6 +66,7 @@ work in a `tailwind.config.js`.
variant: vibrant;
spec-version: 2025;
gamut: display-p3;
colors: replace;
}
```

Expand Down Expand Up @@ -128,12 +131,192 @@ 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.

#### `contrasts`

Material defines every color role at four contrast levels. Only the default one is
generated unless you ask for more, because the other three are two thirds of everything
this plugin emits and most themes never use them:

```css
@plugin "@claas.dev/material-tailwind" {
source-color: #0c1445;
contrasts: high;
}
```

That adds `bg-high-contrast-primary`, `bg-light-high-contrast-primary` and the rest of the
high contrast roles. The list is not exclusive, and any separator that reads naturally
works — `high medium`, `high, medium`, or `all` for every level.

The `default` level is always generated whether or not you list it, because the
unqualified roles (`bg-primary`, `text-on-surface`) come from it.

# 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
.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 every utility built on it.

Composites are never declared anywhere — they exist only as the fallback above. That means
you can override either level, and from anywhere:

```css
/* Retint every default-contrast color that builds on it. */
:root {
--color-light-primary: #ff0000;
}

/* Or replace one composite outright, light and dark together. */
@theme {
--color-primary: #ff0000;
}
```

### The leaves are registered with `@property`

```css
@property --color-light-primary {
syntax: "<color>";
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 `<color>` 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, every leaf ships whether
or not a utility uses it — Tailwind's tree shaking does not apply to what a plugin writes.
That is 227 registrations, about 1.83 kB gzipped, at the default contrast. Adding all four
contrast levels raises it to 581 registrations and about 4.20 kB.

Composites cost nothing when unused, since they live in the utilities rather than in a
block of their own. In the [example app](example) the stylesheet went from 3.90 kB to
6.27 kB gzipped. See [docs/tailwind-plugin-api.md](docs/tailwind-plugin-api.md) for why
the fixed part cannot be tree-shaken away.

# 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).

# Known issues

### Tailwind CSS IntelliSense shows no color swatch

The colored square the VS Code extension draws next to a color utility does not appear for
the colors this plugin generates. `bg-red-500` still gets one, `bg-primary` does not.

The extension resolves a utility's value and then asks Tailwind's design system for any
theme variable in it. A plugin cannot register a real theme variable, so the design system
has never heard of `--color-primary`, the `var()` is left unresolved, and the value stops
being parseable as a color.

There is no way to fix this from inside a plugin. It is the same limitation that makes the
colors overridable in the first place, seen from the other side — see
[docs/tailwind-plugin-api.md](docs/tailwind-plugin-api.md).

### `ERR_MODULE_NOT_FOUND` from `@material/material-color-utilities`

`@material/material-color-utilities@0.4.0` is an ESM-only package, but some of its
Expand Down
Loading