npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

material-theme-builder

v5.2.0

Published

m3 color-system for JS/TS ecosystem

Readme

npm version

Outputs m3 colors --md-sys-color-*, 1:1 with Material Theme Builder, and the --md-ref-palette-* tonal palettes they are drawn from, either:

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: source or primary if defined
  • [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 its schemes match it. Its palettes, though, are the reference palettes: the palettes the system colors are drawn from, the same ones, tones and values as the --md-ref-palette-* of toCss() (error and 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 its schemes come 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]

colorMatch takes precedence over scheme -- Material Theme Builder has no scheme selector: Color match off is tonalSpot, on is content. 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-match

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.

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

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 (brand and Brand). 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 the dark class 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-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>.

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

[!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.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

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

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.

see:

  • https://chatgpt.com/share/6899f20a-422c-8011-a072-62fb649589a0
  • https://gemini.google.com/share/51e072b6f1d2

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

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: