The Material Design 3 color system for Tailwind CSS 4, with dynamic color.
Documentation and live playground
Generate a complete, accessible M3 palette from a single source color, use it through ordinary Tailwind utilities, and swap the whole theme at runtime without a rebuild.
/* app.css */
@import "tailwindcss";
@plugin "materialwind-css" {
primary: #506546;
}<button class="interactive-primary rounded-full px-6 py-2">Press me</button>
<div class="bg-surface-container-high text-on-surface">Card</div>npm install materialwind-cssRequires tailwindcss@^4.
Everything is configured in the @plugin block. Only a primary (or source)
color is required.
@import "tailwindcss";
@plugin "materialwind-css" {
primary: #506546; /* seeds the scheme; everything else derives from it */
scheme: tonalSpot; /* scheme variant */
contrast: 0; /* -1 (minimum) to 1 (maximum) */
darkMode: class; /* media | class | any selector */
/* any other key is a custom color */
brand: #ff0000;
success: #00c853;
}| Option | Type | Default | Notes |
|---|---|---|---|
source |
hex | none | The seed color. Optional if primary is given. |
primary / secondary / tertiary |
hex | derived | Pin a core role instead of deriving it. See below. |
neutral / neutralVariant / error |
hex | derived | Same, for the surface and error palettes. |
scheme |
see below | tonalSpot |
Scheme variant. |
contrast |
-1 to 1 |
0 |
0 is the spec'd design; clamped. |
specVersion |
2021 | 2025 |
2021 |
See note below. |
darkMode |
media | class | selector |
media |
Any other string is used verbatim as a selector. |
prefix |
string | mw |
Custom property prefix, e.g. --mw-primary. |
harmonize |
boolean | true |
Default harmonization for custom colors. |
stateHover / stateFocus / statePress / stateDrag |
number | 8 / 12 / 12 / 16 |
State-layer opacity, in percent. |
transition |
number | false |
150 |
Interactive transition duration in ms. |
Schemes: content, expressive, fidelity, fruitSalad, monochrome,
neutral, rainbow, tonalSpot, vibrant.
On
specVersion: onlytonalSpot,vibrant,expressiveandneutralhonour2025. Upstream forces every other scheme back to2021regardless of what you pass.
By default every role is derived from the source color. You can pin any of them instead. The rest keep being derived and stay in harmony:
@plugin "materialwind-css" {
primary: #506546;
secondary: #ffff00;
}primary doubles as the seed, so source is only needed if you want the scheme
derived from a different color than your primary.
Pinning takes the hue of the color you give and the chroma of the scheme
it is joining. That is deliberate: letting an arbitrary hex bring its own chroma
is what makes hand-assembled palettes look garish, and it would break the
contrast guarantees the tonal system provides. So #ffff00 gives you a yellow
secondary that belongs to your palette, not literal yellow.
Pinnable roles: primary, secondary, tertiary, neutral, neutralVariant,
error. neutral and neutralVariant drive the surface family.
Core colors are not harmonized. You picked the hue, so it is respected. Custom colors are harmonized by default, since they're extras being fitted in.
Any option key that isn't listed above is treated as a custom color. Each one produces a full four-role group, harmonized toward the source color by default:
@plugin "materialwind-css" {
source: #506546;
brand: #ff0000;
}gives you brand, on-brand, brand-container and on-brand-container, plus
surface-brand / interactive-brand / dragged-brand.
To opt a color out of harmonization, or for anything else the flat CSS syntax can't express, use a JS config:
// tailwind.config.js
import materialwind from "materialwind-css";
export default {
plugins: [
materialwind({
source: "#506546",
colors: {
brand: { hex: "#ff0000", harmonize: false },
},
states: { hover: 10 },
}),
],
};Every token becomes an ordinary Tailwind color, so it works with bg-, text-,
border-, outline-, fill-, stroke-, ring-, divide-, shadow-, the
/opacity modifier, and every variant.
All 59 M3 dynamic color tokens are generated:
Surfaces. background, on-background, surface, surface-dim,
surface-bright, surface-container-lowest, surface-container-low,
surface-container, surface-container-high, surface-container-highest,
on-surface, surface-variant, on-surface-variant, outline,
outline-variant, inverse-surface, inverse-on-surface, shadow, scrim,
surface-tint
Primary. primary, primary-dim, on-primary, primary-container,
on-primary-container, inverse-primary, primary-fixed, primary-fixed-dim,
on-primary-fixed, on-primary-fixed-variant
Secondary. secondary, secondary-dim, on-secondary,
secondary-container, on-secondary-container, secondary-fixed,
secondary-fixed-dim, on-secondary-fixed, on-secondary-fixed-variant
Tertiary. tertiary, tertiary-dim, on-tertiary, tertiary-container,
on-tertiary-container, tertiary-fixed, tertiary-fixed-dim,
on-tertiary-fixed, on-tertiary-fixed-variant
Error. error, error-dim, on-error, error-container,
on-error-container
Palette key colors. primary-palette-key-color,
secondary-palette-key-color, tertiary-palette-key-color,
neutral-palette-key-color, neutral-variant-palette-key-color,
error-palette-key-color
M3 pairs every container color with an on-color for its content. Three utilities apply that pairing for you.
| Utility | Effect |
|---|---|
surface-X |
background: X, color: on-X |
interactive-X |
the above, plus hover / focus-visible / press state layers and a transition |
dragged-X |
the above with the drag state layer applied permanently |
<button class="interactive-primary">Primary</button>
<div class="surface-container-high">Card</div>The redundant surface segment is dropped from the class name, so surface
tokens read naturally: surface, surface-container-high, surface-variant,
surface-inverse. The literal token name (surface-surface-container-high)
also works if you prefer it.
State layers follow the M3 guidelines: the on-color is mixed into the container
color at 8% (hover), 12% (focus and press) and 16% (drag), using native
color-mix(). The plain background color is always declared first, so a browser
without color-mix() still gets the correct surface.
These exist for every token that has an on-color, and for every custom color.
The plugin emits the palette as custom properties (--mw-primary, …) and points
the Tailwind colors at them. Re-theming at runtime is therefore just rewriting
those properties. No rebuild, and every utility updates at once.
import { updateTheme } from "materialwind-css/runtime";
updateTheme({
source: "#ff0000",
scheme: "tonalSpot",
contrast: 0,
darkMode: "class", // must match your @plugin config
});updateTheme regenerates the full palette and injects (or replaces) a single
<style id="materialwind-dynamic-theme"> element. It returns the CSS string, so
it can also be used for SSR.
Pass the same prefix, darkMode and custom colors you configured at build
time, otherwise the variables it writes won't be the ones your utilities read.
The runtime pulls in the Material color engine, so import it lazily if initial bundle size matters:
const { updateTheme } = await import("materialwind-css/runtime");Arbitrary color values work. bg-[#000000] compiles correctly. materialwind
never registers a generator under a core utility namespace. Doing so makes
every arbitrary bg-[…] ambiguous and Tailwind emits nothing. There are
regression tests for this.
Tailwind's default palette is preserved. Tokens are added via
theme.extend, so bg-blue-500 and friends still work.
The Material library is bundled. @material/material-color-utilities@0.4.0
ships extensionless relative imports that Node's ESM resolver rejects;
materialwind bundles it so consumers never hit that.
import materialwind, { buildPalette, TOKENS, ON_PAIRS } from "materialwind-css";
import { updateTheme } from "materialwind-css/runtime";buildPalette(options)→{ light, dark, surfaces, onPairs }: the raw generator, if you want the palette without Tailwind.TOKENS: the complete token list.ON_PAIRS: container token to on-color token.
npm install
npm test
npm run buildThe documentation site lives in saade/materialwind-docs and installs this package from npm. To preview an unreleased change there, point its dependency at a local checkout of this repo.
Publishing is driven by GitHub Releases and uses npm trusted publishing (OIDC), so there is no npm token stored in this repo.
- Bump
versioninpackage.jsonand commit. - Tag and push:
git tag v0.2.0 && git push --tags. - Create a GitHub Release for that tag.
.github/workflows/publish.yml then verifies the tag matches the package
version, installs, builds, tests, and publishes. npm generates a provenance
attestation automatically.
The tag check exists because a release tagged v0.2.0 that ships
package.json 0.1.0 would publish the wrong version under the right name, and
npm has no undo.
Trusted publishing has to be configured once on npmjs.com, under the package's Settings → Trusted Publisher:
| Field | Value |
|---|---|
| Publisher | GitHub Actions |
| Organization or user | saade |
| Repository | materialwind |
| Workflow filename | publish.yml |
| Environment | (leave empty) |
| Allowed actions | npm publish |
npm generally requires the package to exist before a trusted publisher can be configured, so the first release was published manually. Every release since runs from CI.
material-color-utilities by Google (Apache-2.0) is bundled into
dist/ and does the color math: tonal palettes, scheme variants, contrast
levels. See THIRD-PARTY-NOTICES.md.
Other work in the Material and Tailwind space:
- tailwind-material-colors, tailwind-material-surfaces and tailwind-mode-aware-colors by Javier Morales
- material-theme-builder by abernier
MIT. Bundled third-party code keeps its own license; see
THIRD-PARTY-NOTICES.md.