Skip to content
 
 

Latest commit

 

History

229 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

npm version

Outputs m3 colors --md-sys-color-* and --md-ref-palette-*, 1:1 with Material Theme Builder, either:

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: source or primary if defined
  • Shades (aka. "tonals")
  • colorMatch

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 });

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.

React

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>

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>
);

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.

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.

shadcn

Pre-requisites:

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);
}

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.json

shadcn-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
The 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 inline block. 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.json

It 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:

Dev

INSTALL

Pre-requisites:

  • Install nvm, then:
    $ nvm install
    $ nvm use
    $ node -v # make sure your version satisfies package.json#engines.node
    nb: if you want this node version to be your default nvm's one: nvm 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 i

Figma plugin

  1. pnpm run build-figma
  2. In Figma: Plugins → Development → Import plugin from manifest…
  3. Select figma-plugin/manifest.json

Validation

$ pnpm run lgtm

CONTRIBUTING

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 checks

shadcn.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 changeset

This helps us maintain the changelog and version the package appropriately.

Outro

m3 references:

builder roles
CleanShot 2026-01-14 at 08 58 40@2x CleanShot 2026-01-14 at 09 01 23@2x

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 tokenDescriptions is 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:

Releases

Packages

Contributors

Languages