Outputs m3 colors --md-sys-color-* and --md-ref-palette-*, 1:1 with Material Theme Builder, either:
- programmatically
- from CLI
- using React
react-mcu.mp4
Support for:
- light/dark mode
- source color
- scheme
- contrast
- core-colors overrides: primary, secondary, tertiary, error, neutral, neutralVariant
- custom-colors (aka. "Extended colors")
- Harmonization (aka.
blend) -- with effective color:sourceorprimaryif defined
- Harmonization (aka.
- Shades (aka. "tonals")
- colorMatch
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 });$ 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.
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 through
setMtbConfig.
Note
For a theme that is not interactive / never changes at runtime, skip the
component entirely: the root entry holds builder alone, so a Server
Component can call it and emit toCss() into the document itself — no client
JS, and no useMtb.
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>
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>
);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.
Details
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-primary compiles to background-color: var(--md-sys-color-primary), with
no --color-primary in between. That one would sit on :root, out of reach
of a nested <Mtb>.
The names it declares
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.
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().
The three names both halves claim
[!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);
}The variables it remaps
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);
}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.5The rest of the options
--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.
Install the mapping alone, without generating anything
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.
mapping details
see:
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 ipnpm run build-figma- In Figma: Plugins → Development → Import plugin from manifest…
- Select
figma-plugin/manifest.json
$ pnpm run lgtmpnpm 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.
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

