material-theme-builder
v5.2.0
Published
m3 color-system for JS/TS ecosystem
Readme
Outputs m3 colors --md-sys-color-*, 1:1 with Material Theme Builder, and the --md-ref-palette-* tonal palettes they are drawn from, either:
- programmatically
- from CLI
- using React
https://github.com/user-attachments/assets/5b67c961-d7a4-4b64-9356-4ada26bc9be4
Support for:
- [x] light/dark mode
- [x] source color
- [x] scheme
- [x] contrast
- [x] core-colors overrides: primary, secondary, tertiary, error, neutral, neutralVariant
- [x] custom-colors (aka. "Extended colors")
- [x] Harmonization (aka.
blend, on by default) -- with effective color:sourceorprimaryif defined
- [x] Harmonization (aka.
- [x] Shades (aka. "tonals")
- [x] colorMatch (aka. "Color match - Stay true to my color inputs")
Usage
Programmatic API
import { builder } from "material-theme-builder";
const theme = builder("#6750A4", {
scheme: "vibrant",
contrast: 0.5,
primary: "#FF0000",
secondary: "#00FF00",
customColors: [
{ name: "brand", hex: "#FF5733", blend: true },
{ name: "success", hex: "#28A745", blend: false },
],
});
theme.toFigmaTokens();
theme.toJson();
theme.toCss();
theme.toTailwind();
theme.toFlutter();
theme.toShadcn();
theme.toShadcnAliases();
theme.toShadcnRegistryItem({ fallback: true });[!NOTE]
toJson()is shaped like Material Theme Builder's JSON export, and itsschemesmatch it. Itspalettes, though, are the reference palettes: the palettes the system colors are drawn from, the same ones, tones and values as the--md-ref-palette-*oftoCss()(errorand custom colors included, 28 tones). Material Theme Builder's own export holds other palettes -- five, at 18 tones, which ignore the scheme variant and are not the palettes itsschemescome from. They are not reproduced: Material Theme Builder's maintainers say they are only there as reference. See ADR 0001 for why.
colorMatch: true is Material Theme Builder's "Color match - Stay true to my
color inputs": each core color and each custom color is rendered with the
content variant of its own input, instead of being toned down to the scheme's
chroma. A custom color is still harmonized first when its blend is set (the
default): set blend: false to keep it exactly as given.
builder("#6750A4", { secondary: "#B03A3A", colorMatch: true });[!NOTE]
colorMatchtakes precedence overscheme-- Material Theme Builder has no scheme selector: Color match off istonalSpot, on iscontent. See ADR 0003.
CLI
$ npx material-theme-builder "#6750A4"will generate a material-theme folder with: Light.tokens.json and Dark.tokens.json design-tokens files, you can (both) import into Figma.
See npx material-theme-builder --help for all available options.
--color-match is colorMatch: it takes precedence over
--scheme, for the custom colors as well.
$ npx material-theme-builder "#6750A4" --secondary "#B03A3A" --color-matchReact
The React bindings live on their own entry point, material-theme-builder/react.
CSS variables are injected into the page:
import { Mtb } from "material-theme-builder/react";
<Mtb
source="#0e1216"
scheme="vibrant"
contrast={0.5}
customColors={[
{ name: "myCustomColor1", hex: "#6C8A0C", blend: true },
{ name: "myCustomColor2", hex: "#E126C6", blend: true },
{ name: "myCustomColor3", hex: "#E126C6", blend: false },
]}
>
<p style={{
backgroundColor: "var(--md-sys-color-surface)",
color: "var(--md-sys-color-on-surface)",
}}>
Hello, m3 <span style={{
backgroundColor: "var(--md-sys-color-my-custom-color-1)",
color: "var(--md-sys-color-on-my-custom-color-1)",
}}>colors<span>!
</p>
</Mtb>[!TIP]
Typically wrapping
{children}in a layout.
<Mtb>renders its<style>, so it works both server- and client-side. Client-side is what you want when the theme has to be interactive throughsetMtbConfig.
<Mtb> takes the same options as builder, as props --
colorMatch included (<Mtb source="#0e1216" colorMatch>): it takes precedence
over scheme, for the custom colors as well.
[!NOTE]
For a theme that is not interactive / never changes at runtime, skip the component entirely: the root entry holds
builderalone, so a Server Component can call it and emittoCss()into the document itself — no client JS, and nouseMtb.import { builder } from "material-theme-builder"; const css = builder("#0e1216", { scheme: "vibrant" }).toCss(); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <head> <style dangerouslySetInnerHTML={{ __html: css }} /> </head> <body>{children}</body> </html> ); }
[!NOTE]
CSS varnames are always kebab-cased, e.g.
myCustomColor1→--md-sys-color-my-custom-color-1/--md-ref-palette-my-custom-color-1-<tone>So a custom color needs a name of its own, once kebab-cased:
builder()and<Mtb>throw on one that collides with a system role (secondary,onSurface), a core palette (neutral) or another custom color (brandandBrand). To replace a core color, use its override (secondary: "#...").
useMtb
A hook is also provided:
import { useMtb } from "material-theme-builder/react";
const { initials, setMtbConfig, getMtbColor } = useMtb();
return (
<button onClick={() => setMtbConfig({ ...initials, source: "#FF5722" })}>
Change to {getMtbColor("primary", "light")}
</button>
);Scheme poster
The poster the stories draw — every M3 role of the theme, light and dark, and its tonal palettes:
import { Mtb, Poster, Scheme, Shades } from "material-theme-builder/react";
<Mtb source="#769CDF">
<Poster style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
<Scheme theme="light" title="Light scheme" />
<Scheme theme="dark" title="Dark scheme" />
<Shades />
</Poster>
</Mtb>;Poster is the frame — gaps, labels, cell heights — so keep Scheme and
Shades inside one. It needs no Tailwind: its layout is inline styles, so
nothing has to scan node_modules for class names. Custom colors are passed
along (customColors={...}, as given to <Mtb>), and Scheme has opt-in rows
for the roles the spec dropped (background, surfaceVariant, surfaceTint).
[!NOTE]
theme="dark"adds thedarkclass the dark values are keyed on;theme="light"cannot take an ancestor's away, so on a page in dark mode it shows the dark values too.
Tailwind
Compatible through theme variables — one plugin, one line:
@import "tailwindcss";
@plugin "material-theme-builder/tailwind" {
custom-colors: myCustomColor1, myCustomColor2;
}Drop the custom-colors block if you have none.
Each name listed brings its four scheme roles and eleven shades —
bg-myCustomColor1, text-on-myCustomColor1, bg-myCustomColor1-container,
bg-myCustomColor1-300.
prefix mirrors builder({ prefix }):
@plugin "material-theme-builder/tailwind" {
prefix: my;
custom-colors: myCustomColor1;
}[!TIP]
Colors are declared as inlined theme values:
bg-primarycompiles tobackground-color: var(--md-sys-color-primary), with no--color-primaryin between. That one would sit on:root, out of reach of a nested<Mtb>.
115 standard ones — every M3 scheme token (bg-surface-container-low,
text-on-primary, border-outline-variant…), plus eleven Tailwind shades for
each of primary, secondary, tertiary, error, neutral and
neutral-variant (bg-primary-300). Then four roles and eleven shades per
custom color you name.
They are theme defaults, so an @theme block of your own wins over them
whatever the order. See shadcn, where three names collide.
shadcn
Pre-requisites:
- You should use
tailwind.cssVariables
In your
globals.css:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* 👇🏻 ADD THIS 👇🏻 */
@import "material-theme-builder/shadcn.css"; /* shadcn's variables remapping on M3 */
@plugin "material-theme-builder/tailwind" { /* the M3 tw classNames (optional) */
custom-colors: myCustomColor1, myCustomColor2;
}
/* 👆🏻 ADD THIS 👆🏻 */
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
...
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
...
}
.dark {
--background: oklch(0.145 0 0);
...
}shadcn.css is the one that matters: it points
shadcn's variables at
the M3 custom properties, so every shadcn component follows whichever <Mtb> is
above it in the tree. It carries no colors of its own — mount an <Mtb>, or
emit toCss() server-side, or nothing resolves.
The @plugin line is optional. It is the Tailwind recipe
unchanged, and what it adds is names to write yourself —
bg-surface-container-low, text-on-primary, your custom colors. Drop it and
every shadcn component still follows the theme.
For the opposite trade — concrete oklch() values and no var() at all, frozen
at build time — see toShadcn().
[!NOTE]
Written down for the record. It moves one utility by one role, and you almost certainly do not need to care.
Material and shadcn picked the same name for three things — background,
primary, secondary. The plugin's colors are theme defaults, so on those
three shadcn's @theme inline wins, and the utility goes through the mapping
above:
bg-secondary → --color-secondary → var(--secondary) → var(--md-sys-color-secondary-container)Without shadcn it is one hop shorter, and lands on the role of the same name:
bg-secondary → --color-secondary → var(--md-sys-color-secondary)Same destination either way, M3 — just not the same role. And only for
secondary: primary maps to primary, and M3 background and surface are
the same color.
If you ever want the M3 role itself, <Mtb> still emits it:
<div class="bg-[var(--md-sys-color-secondary)]"></div>or give it a name of its own:
@theme inline {
--color-m3-secondary: var(--md-sys-color-secondary);
}Both halves are generated from toShadcnAliases() and
toShadcnRegistryItem(), off one mapping, so they cannot
drift. The selectors are doubled so the block outranks shadcn's own :root and
.dark on
specificity
rather than on order — which is what lets the @import sit with your others.
:root:root,
.dark.dark {
--background: var(--md-sys-color-surface);
--foreground: var(--md-sys-color-on-surface);
--card: var(--md-sys-color-surface-container-low);
--card-foreground: var(--md-sys-color-on-surface);
--popover: var(--md-sys-color-surface-container-high);
--popover-foreground: var(--md-sys-color-on-surface);
--primary: var(--md-sys-color-primary);
--primary-foreground: var(--md-sys-color-on-primary);
--secondary: var(--md-sys-color-secondary-container);
--secondary-foreground: var(--md-sys-color-on-secondary-container);
--muted: var(--md-sys-color-surface-container-highest);
--muted-foreground: var(--md-sys-color-on-surface-variant);
--accent: var(--md-sys-color-secondary-container);
--accent-foreground: var(--md-sys-color-on-secondary-container);
--destructive: var(--md-sys-color-error);
--border: var(--md-sys-color-outline-variant);
--input: var(--md-sys-color-outline);
--ring: var(--md-sys-color-primary);
--chart-1: var(--md-sys-color-primary-fixed);
--chart-2: var(--md-sys-color-secondary-fixed);
--chart-3: var(--md-sys-color-tertiary-fixed);
--chart-4: var(--md-sys-color-primary-fixed-dim);
--chart-5: var(--md-sys-color-secondary-fixed-dim);
--sidebar: var(--md-sys-color-surface-container-low);
--sidebar-foreground: var(--md-sys-color-on-surface);
--sidebar-primary: var(--md-sys-color-primary);
--sidebar-primary-foreground: var(--md-sys-color-on-primary);
--sidebar-accent: var(--md-sys-color-secondary-container);
--sidebar-accent-foreground: var(--md-sys-color-on-secondary-container);
--sidebar-border: var(--md-sys-color-outline-variant);
--sidebar-ring: var(--md-sys-color-primary);
}shadcn-apply
The alternative, for colors to fall back on and no import to place. One command, from inside your project:
$ npx material-theme-builder shadcn-apply "#6750A4"From nothing at all, scaffold with shadcn's own CLI first — what this repo dogfoods:
$ npx shadcn@latest init --preset b0 --name material-theme-app
$ cd material-theme-app && npx material-theme-builder shadcn-apply "#6750A4"It generates a registry item for your source color and hands it to shadcn add,
which rewrites the values inside your existing :root and .dark blocks, in
place. Same mapping as the stylesheet, with that theme's own colors left in as
the var() fallbacks:
:root {
--card: var(--md-sys-color-surface-container-low, oklch(0.968 0.012 317.742));
}
.dark {
--card: var(--md-sys-color-surface-container-low, oklch(0.227 0.01 303.714));
}So it works with no <Mtb> at all — the fallbacks render the theme statically,
server-rendered, zero client JS. Your old values are overwritten, not kept
anywhere: git diff is the undo.
Both steps by hand, if you would rather:
$ npx material-theme-builder "#6750A4" --format registry-item > mtb.json
$ npx shadcn@latest add ./mtb.json && rm mtb.jsonshadcn-apply takes every theme option material-theme-builder itself takes,
and they all land in those fallbacks:
$ npx material-theme-builder shadcn-apply "#6750A4" --scheme vibrant --contrast 0.5--no-fallback leaves the fallbacks out, on both — so shadcn's own colors are
dropped rather than kept in reserve. Nothing then declares those variables
except an <Mtb> or a toCss(): without one, they resolve
to nothing and the components render transparent.
--custom-colors is the one option missing: shadcn's variable set is fixed, so
a registry item cannot carry one.
Anything after a -- is forwarded verbatim to shadcn add. Our options go
before it:
$ npx material-theme-builder shadcn-apply "#6750A4" -- --overwrite --dry-run[!NOTE]
shadcn's CLI also appends a self-referential
--card: var(--card);per variable to your@theme inlineblock. Noise, not a bug: they land above your:root, so the real values win. Delete them if they bother you.
The package publishes a registry item too, so shadcn add has something to
fetch without a build step:
$ npx shadcn@latest add https://unpkg.com/material-theme-builder/registry-item.jsonIt is the stylesheet's content, installed the registry way: the mapping and nothing else, no colors to fall back on. Generate your own, as above, to have some.
see:
- https://chatgpt.com/share/6899f20a-422c-8011-a072-62fb649589a0
- https://gemini.google.com/share/51e072b6f1d2
Dev
INSTALL
Pre-requisites:
- Install nvm, then:
nb: if you want this node version to be your default nvm's one:$ nvm install $ nvm use $ node -v # make sure your version satisfies package.json#engines.nodenvm alias default node - Install pnpm, with:
$ corepack enable $ corepack prepare --activate # it reads "packageManager" $ pnpm -v # make sure your version satisfies package.json#engines.pnpm
$ pnpm iFigma plugin
pnpm run build-figma- In Figma: Plugins → Development → Import plugin from manifest…
- Select
figma-plugin/manifest.json
Validation
$ pnpm run lgtmCONTRIBUTING
pnpm run storybook # the day-to-day loop -- no build needed, `shadcn.css` regenerates as you edit
pnpm run build # dist/, plus the generated files -- all gitignored
pnpm run lgtm # everything CI checksshadcn.css and registry-item.json are generated — from toShadcnAliases()
and toShadcnRegistryItem() — and gitignored. pnpm run build writes them
(scripts/generate.mjs); shadcn.css also gets a src/ copy, which is what
Storybook @imports, and in Storybook a Vite plugin (.storybook/main.ts)
rewrites it at server start and again on every edit under src/lib/, so the
stories never show a stale vocabulary.
generate.mjs builds the registry item without { fallback: true }, which is
what keeps every one of those outputs a function of the mapping rather than of
a color: SOURCE there is arbitrary, and has to stay able to be. The fallback
variant belongs to whoever knows a real source color — the CLI's
--format registry-item.
src/styles/shadcn.css is the other half of that arrangement, and is not
generated from anything here: it is pristine shadcn init --preset b0 output,
committed verbatim — regenerate it with the recipe in its own header. Same for
the components, via pnpm dlx shadcn@latest add <item> --overwrite. All of it
is exempt from Prettier and from the repo's own lint conventions, so that a
regeneration diffs to nothing; see .prettierignore and SHADCN_FILES in
eslint.config.mjs for which paths components.json makes shadcn's territory.
The Shadcn/dashboard-01 story is what checks the shadcn mapping end to end: it
renders one of shadcn's blocks, unmodified,
under <Mtb>. Every other story paints from the M3 vocabulary directly, so none
of them would notice shadcn.css pointing a variable at the wrong role.
When submitting a pull request, please include a changeset to document your changes:
pnpm exec changesetThis helps us maintain the changelog and version the package appropriately.
Outro
m3 references:
| builder | roles | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | | |
The spec itself, deep-linked to the sections that matter. m3.material.io is a
client-rendered SPA, so #:~:text= fragments get stripped on load — only these
section anchors work:
- Color roles — the inventory:
"26 standard color roles organized into six groups", which is what
tokenDescriptionsis checked against - Color roles § Surface
— "three surface roles: Surface / On surface / On surface variant". No
surface variant: the ink outlived its own background, hence the asymmetry - Color roles § Add-on color roles — fixed accents and surface dim/bright are add-ons, and "most products won't need to use these"
- Color system § What's new — the changelog. Feb 2023 is when tone-based surfaces replaced the +1…+5 elevation model
The Material Design blog is where the reasoning behind the color system lives — and where changes to it get announced before the spec pages catch up:
- Tone-based Surfaces in Material 3
— the surface roles replacing elevation overlays. The only first-party text
stating that
Surface Variantgives way toSurface Container Highest - The science of color & design — HCT, and why a tone means the same contrast across hues: the basis of the tonal palettes
- Designing Harmony into Dynamic Color
— what
customColors[].blendactually does to a custom color - Introducing Material Theme Builder — the tool this package reimplements
