From mui-expert
MUI CSS Variables mode (CssVarsProvider), Pigment CSS zero-runtime engine, and CSS custom properties theming
npx claudepluginhub markus41/claude --plugin mui-expertThis skill is limited to using the following tools:
`CssVarsProvider` replaces `ThemeProvider` when you want CSS-variable-based theming. Instead of injecting theme values into a JS context that triggers React re-renders on change, it emits CSS custom properties on the root element. Color scheme switches happen in CSS alone — no React tree re-render.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
CssVarsProvider replaces ThemeProvider when you want CSS-variable-based theming. Instead of injecting theme values into a JS context that triggers React re-renders on change, it emits CSS custom properties on the root element. Color scheme switches happen in CSS alone — no React tree re-render.
import { CssVarsProvider, extendTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: { main: '#1976d2' },
background: { default: '#fafafa' },
},
},
dark: {
palette: {
primary: { main: '#90caf9' },
background: { default: '#121212' },
},
},
},
});
function App() {
return (
<CssVarsProvider theme={theme}>
<CssBaseline enableColorScheme />
{/* your app */}
</CssVarsProvider>
);
}
| Feature | ThemeProvider + createTheme | CssVarsProvider + extendTheme |
|---|---|---|
| Theme values in CSS | No (JS only) | Yes (CSS custom properties) |
| Dark/light toggle | Re-renders entire tree | CSS-only, no re-render |
| SSR flash prevention | Requires manual script | Built-in getInitColorSchemeScript() |
| Theme access in sx/styled | theme.palette.primary.main | theme.vars.palette.primary.main or var(--mui-palette-primary-main) |
| Multiple color schemes | Separate themes, context switch | Single theme object with colorSchemes |
Traditional API. Produces a theme object consumed by ThemeProvider. No CSS variables emitted. Good for projects that do not need CSS-variable-based theming or SSR flash prevention.
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
primary: { main: '#90caf9' },
},
});
// Used with <ThemeProvider theme={theme}>
Produces a CSS-variable-aware theme for CssVarsProvider. Accepts colorSchemes to define light and dark palettes in a single object. Automatically generates CSS custom properties.
import { extendTheme, CssVarsProvider } from '@mui/material/styles';
const theme = extendTheme({
cssVarPrefix: 'app', // default: 'mui'
colorSchemes: {
light: {
palette: {
primary: { main: '#1976d2' },
secondary: { main: '#9c27b0' },
},
},
dark: {
palette: {
primary: { main: '#90caf9' },
secondary: { main: '#ce93d8' },
},
},
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", sans-serif',
},
shape: { borderRadius: 12 },
});
// Used with <CssVarsProvider theme={theme}>
Use extendTheme + CssVarsProvider when:
Use createTheme + ThemeProvider when:
ThemeProvider contextcolorSchemes replaces the palette.mode approach. Define all schemes in one object:
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: { main: '#1565c0', light: '#1976d2', dark: '#0d47a1' },
secondary: { main: '#7b1fa2' },
error: { main: '#d32f2f' },
warning: { main: '#ed6c02' },
info: { main: '#0288d1' },
success: { main: '#2e7d32' },
background: { default: '#ffffff', paper: '#f5f5f5' },
text: { primary: '#1a1a1a', secondary: '#666666' },
},
},
dark: {
palette: {
primary: { main: '#90caf9', light: '#bbdefb', dark: '#42a5f5' },
secondary: { main: '#ce93d8' },
error: { main: '#f44336' },
warning: { main: '#ffa726' },
info: { main: '#29b6f6' },
success: { main: '#66bb6a' },
background: { default: '#121212', paper: '#1e1e1e' },
text: { primary: '#ffffff', secondary: '#b0b0b0' },
},
},
},
});
You can define additional schemes. The first key is treated as the default:
const theme = extendTheme({
colorSchemes: {
light: { palette: { /* ... */ } },
dark: { palette: { /* ... */ } },
highContrast: {
palette: {
primary: { main: '#ffff00' },
background: { default: '#000000', paper: '#111111' },
text: { primary: '#ffffff' },
},
},
},
});
// Switch to it:
const { setMode } = useColorScheme();
setMode('highContrast');
<CssVarsProvider theme={theme} defaultMode="dark">
{/* Renders with dark scheme initially */}
</CssVarsProvider>
Supported defaultMode values: 'light', 'dark', 'system' (follows OS preference).
Without this script, SSR apps show the default (light) theme briefly before hydration applies the user's preferred scheme. The script injects a blocking <script> that reads localStorage (or OS preference) and sets the data-* attribute before first paint.
import { getInitColorSchemeScript } from '@mui/material/styles';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
{getInitColorSchemeScript({ defaultMode: 'system' })}
{children}
</body>
</html>
);
}
import { getInitColorSchemeScript } from '@mui/material/styles';
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
{getInitColorSchemeScript({ defaultMode: 'system' })}
<Main />
<NextScript />
</body>
</Html>
);
}
getInitColorSchemeScript({
defaultMode: 'system', // 'light' | 'dark' | 'system'
modeStorageKey: 'app-color-mode', // localStorage key (default: 'mui-mode')
colorSchemeStorageKey: 'app-scheme', // localStorage key for scheme
attribute: 'data-app-color-scheme', // HTML attribute set on root element
colorSchemeNode: 'html', // DOM node to receive the attribute
});
import { useColorScheme } from '@mui/material/styles';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness';
function ThemeToggle() {
const { mode, setMode } = useColorScheme();
if (!mode) return null; // avoids SSR hydration mismatch
return (
<Button
onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
startIcon={mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
>
{mode === 'dark' ? 'Light mode' : 'Dark mode'}
</Button>
);
}
function ThemeSwitcher() {
const { mode, setMode } = useColorScheme();
if (!mode) return null;
const options = [
{ value: 'light', icon: <LightModeIcon />, label: 'Light' },
{ value: 'dark', icon: <DarkModeIcon />, label: 'Dark' },
{ value: 'system', icon: <SettingsBrightnessIcon />, label: 'System' },
] as const;
return (
<>
{options.map((opt) => (
<IconButton
key={opt.value}
onClick={() => setMode(opt.value)}
color={mode === opt.value ? 'primary' : 'default'}
aria-label={opt.label}
>
{opt.icon}
</IconButton>
))}
</>
);
}
Why no re-render: setMode updates a data-* attribute on the root HTML element and writes to localStorage. CSS variables respond to the attribute via CSS selectors (e.g., [data-mui-color-scheme="dark"]). React components only re-render if they read mode — the actual style changes are pure CSS.
<Box
sx={{
// Option 1: Use the var() function directly
color: 'var(--mui-palette-primary-main)',
backgroundColor: 'var(--mui-palette-background-paper)',
border: '1px solid var(--mui-palette-divider)',
// Option 2: Use theme.vars (type-safe, recommended)
// This is available inside callback form:
color: (theme) => theme.vars.palette.primary.main,
p: 2,
borderRadius: 'var(--mui-shape-borderRadius)',
}}
/>
import { styled } from '@mui/material/styles';
const StyledCard = styled('div')(({ theme }) => ({
// theme.vars resolves to CSS var references like var(--mui-palette-...)
// This is SSR-safe because it emits CSS variables, not resolved values
backgroundColor: theme.vars.palette.background.paper,
color: theme.vars.palette.text.primary,
padding: theme.spacing(3),
borderRadius: theme.vars.shape.borderRadius,
boxShadow: theme.vars.shadows[4],
border: `1px solid ${theme.vars.palette.divider}`,
'&:hover': {
backgroundColor: theme.vars.palette.action.hover,
},
}));
/* styles.module.css */
.card {
background-color: var(--mui-palette-background-paper);
color: var(--mui-palette-text-primary);
border: 1px solid var(--mui-palette-divider);
border-radius: var(--mui-shape-borderRadius);
padding: 16px;
}
/* Dark mode styles — no extra class needed, CSS vars change automatically */
const theme = extendTheme({
cssVarPrefix: 'app',
// ...
});
// CSS variables are now:
// var(--app-palette-primary-main)
// var(--app-palette-background-default)
// var(--app-shape-borderRadius)
MUI transforms the nested theme object into flat CSS custom properties:
| Theme path | CSS variable |
|---|---|
theme.palette.primary.main | --mui-palette-primary-main |
theme.palette.background.default | --mui-palette-background-default |
theme.palette.text.secondary | --mui-palette-text-secondary |
theme.shape.borderRadius | --mui-shape-borderRadius |
theme.shadows[4] | --mui-shadows-4 |
theme.palette.action.hover | --mui-palette-action-hover |
theme.spacing(2) | Computed (not a CSS var by default) |
const StyledBox = styled('div')(({ theme }) => ({
// WRONG for SSR with CssVarsProvider — resolves at build time, mismatches on hydration
// color: theme.palette.primary.main,
// CORRECT — emits var(--mui-palette-primary-main), works with SSR
color: theme.vars.palette.primary.main,
// theme.vars is only available when using CssVarsProvider + extendTheme.
// With ThemeProvider + createTheme, use theme.palette.primary.main directly.
}));
Add custom tokens via the theme that become CSS variables:
declare module '@mui/material/styles' {
interface CssVarsThemeOptions {
custom?: {
headerHeight?: string;
sidebarWidth?: string;
contentMaxWidth?: string;
};
}
interface Theme {
custom: {
headerHeight: string;
sidebarWidth: string;
contentMaxWidth: string;
};
}
}
const theme = extendTheme({
custom: {
headerHeight: '64px',
sidebarWidth: '280px',
contentMaxWidth: '1200px',
},
colorSchemes: { light: {}, dark: {} },
});
// Access in styled:
// theme.vars.custom.headerHeight → var(--mui-custom-headerHeight)
Pigment CSS is MUI's compile-time CSS extraction engine. It replaces Emotion as the styling runtime — all styled() and css() calls are evaluated at build time and extracted to static CSS files. No JavaScript styling runtime is shipped to the browser.
keyframes, Global, css prop)Install:
npm install @pigment-css/react @pigment-css/nextjs-plugin
Configure next.config.mjs:
import { withPigment } from '@pigment-css/nextjs-plugin';
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withPigment(nextConfig, {
theme: {
cssVarPrefix: 'app',
colorSchemes: {
light: {
palette: {
primary: { main: '#1976d2' },
background: { default: '#ffffff' },
},
},
dark: {
palette: {
primary: { main: '#90caf9' },
background: { default: '#121212' },
},
},
},
typography: {
fontFamily: '"Inter", sans-serif',
},
},
});
npm install @pigment-css/react @pigment-css/vite-plugin
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { pigment } from '@pigment-css/vite-plugin';
export default defineConfig({
plugins: [
pigment({
theme: {
colorSchemes: {
light: { palette: { primary: { main: '#1976d2' } } },
dark: { palette: { primary: { main: '#90caf9' } } },
},
},
}),
react(),
],
});
import { css, styled } from '@pigment-css/react';
// css() — returns a class name string (evaluated at build time)
const cardStyles = css(({ theme }) => ({
backgroundColor: theme.vars.palette.background.paper,
borderRadius: theme.vars.shape.borderRadius,
padding: theme.spacing(3),
boxShadow: theme.vars.shadows[2],
}));
function Card({ children }: { children: React.ReactNode }) {
return <div className={cardStyles}>{children}</div>;
}
// styled() — creates a styled component (evaluated at build time)
const StyledButton = styled('button')(({ theme }) => ({
backgroundColor: theme.vars.palette.primary.main,
color: theme.vars.palette.primary.contrastText,
border: 'none',
padding: `${theme.spacing(1)} ${theme.spacing(3)}`,
borderRadius: theme.vars.shape.borderRadius,
cursor: 'pointer',
fontSize: theme.typography.button.fontSize,
fontWeight: theme.typography.button.fontWeight,
'&:hover': {
backgroundColor: theme.vars.palette.primary.dark,
},
}));
const StyledChip = styled('span')<{ variant?: 'filled' | 'outlined'; color?: 'primary' | 'error' }>(
({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`,
borderRadius: '16px',
fontSize: '0.8125rem',
}),
{
variants: [
{
props: { variant: 'filled', color: 'primary' },
style: ({ theme }) => ({
backgroundColor: theme.vars.palette.primary.main,
color: theme.vars.palette.primary.contrastText,
}),
},
{
props: { variant: 'outlined', color: 'primary' },
style: ({ theme }) => ({
border: `1px solid ${theme.vars.palette.primary.main}`,
color: theme.vars.palette.primary.main,
backgroundColor: 'transparent',
}),
},
{
props: { variant: 'filled', color: 'error' },
style: ({ theme }) => ({
backgroundColor: theme.vars.palette.error.main,
color: theme.vars.palette.error.contrastText,
}),
},
],
},
);
No dynamic runtime styles: Styles are extracted at build time. You cannot do:
// WRONG with Pigment CSS — width is unknown at compile time
const Box = styled('div')<{ w: number }>(({ w }) => ({
width: `${w}px`,
}));
Instead, use CSS variables or predefined variants:
// CORRECT — use inline style for truly dynamic values
function Box({ width, children }: { width: number; children: React.ReactNode }) {
return (
<div className={boxStyles} style={{ '--box-width': `${width}px` } as React.CSSProperties}>
{children}
</div>
);
}
const boxStyles = css({ width: 'var(--box-width)' });
Theme must be serializable: The theme passed to the plugin config must be plain JSON-serializable. No functions, class instances, or circular references.
Build plugin required: Pigment CSS does nothing without the Next.js/Vite/Webpack plugin. The css() and styled() calls are transformed at compile time by the plugin.
Emotion APIs not available: keyframes, Global, ClassNames, and the css prop from @emotion/react are not supported. Use @pigment-css/react equivalents.
Migration is incremental: You can use Pigment CSS alongside Emotion during migration. Components using @pigment-css/react are statically extracted; those still using @mui/material/styles use Emotion at runtime.
// BEFORE (Emotion runtime)
import { styled } from '@mui/material/styles';
const Card = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
}));
// AFTER (Pigment CSS zero-runtime)
import { styled } from '@pigment-css/react';
const Card = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
backgroundColor: theme.vars.palette.background.paper, // note: theme.vars
}));
Key migration steps:
@mui/material/styles to @pigment-css/reacttheme.palette.* with theme.vars.palette.* in styled/css callsstyle prop@emotion/react and @emotion/styled when fully migrated// theme.d.ts or inline in theme file
import '@mui/material/styles';
declare module '@mui/material/styles' {
interface PaletteOptions {
brand?: {
primary?: string;
secondary?: string;
accent?: string;
};
}
interface Palette {
brand: {
primary: string;
secondary: string;
accent: string;
};
}
interface TypeBackground {
subtle?: string;
emphasis?: string;
}
// Extend CSS variables
interface ThemeVars {
palette: Palette & {
brand: {
primary: string;
secondary: string;
accent: string;
};
};
}
}
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
brand: {
primary: '#FF6B35',
secondary: '#004E89',
accent: '#F7C948',
},
background: {
subtle: '#f8f9fa',
emphasis: '#e9ecef',
},
},
},
dark: {
palette: {
brand: {
primary: '#FF8C5A',
secondary: '#3A8FD6',
accent: '#FFD970',
},
background: {
subtle: '#1a1a2e',
emphasis: '#16213e',
},
},
},
},
});
// Type-safe access:
// theme.vars.palette.brand.primary → var(--mui-palette-brand-primary)
declare module '@mui/material/Button' {
interface ButtonPropsColorOverrides {
brand: true;
}
}
// Now <Button color="brand"> is type-safe
// theme.ts
import { extendTheme } from '@mui/material/styles';
export const theme = extendTheme({
cssVarPrefix: 'app',
colorSchemes: {
light: {
palette: {
primary: { main: '#1565c0' },
secondary: { main: '#7b1fa2' },
background: { default: '#fafafa', paper: '#ffffff' },
},
},
dark: {
palette: {
primary: { main: '#90caf9' },
secondary: { main: '#ce93d8' },
background: { default: '#0a0a0a', paper: '#1e1e1e' },
},
},
},
typography: {
fontFamily: '"Inter", "Roboto", sans-serif',
h1: { fontSize: '2.5rem', fontWeight: 700 },
},
shape: { borderRadius: 12 },
});
// layout.tsx (Next.js App Router)
import { getInitColorSchemeScript } from '@mui/material/styles';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { CssVarsProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { theme } from './theme';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
{getInitColorSchemeScript({ defaultMode: 'system' })}
<AppRouterCacheProvider>
<CssVarsProvider theme={theme} defaultMode="system">
<CssBaseline enableColorScheme />
{children}
</CssVarsProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}
// components/ThemeToggle.tsx
'use client';
import { useColorScheme } from '@mui/material/styles';
import IconButton from '@mui/material/IconButton';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
export function ThemeToggle() {
const { mode, setMode } = useColorScheme();
if (!mode) return null;
return (
<IconButton
onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}
sx={{
color: 'var(--app-palette-text-primary)',
}}
>
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
);
}
// CSS Variables mode
import { CssVarsProvider, extendTheme, useColorScheme, getInitColorSchemeScript } from '@mui/material/styles';
// Pigment CSS (zero-runtime)
import { css, styled } from '@pigment-css/react';
// Build plugins
import { withPigment } from '@pigment-css/nextjs-plugin'; // Next.js
import { pigment } from '@pigment-css/vite-plugin'; // Vite
--{prefix}-palette-{color}-{shade} e.g., --mui-palette-primary-main
--{prefix}-palette-text-{type} e.g., --mui-palette-text-secondary
--{prefix}-palette-background-{type} e.g., --mui-palette-background-paper
--{prefix}-palette-action-{type} e.g., --mui-palette-action-hover
--{prefix}-shape-borderRadius e.g., --mui-shape-borderRadius
--{prefix}-shadows-{index} e.g., --mui-shadows-4
--{prefix}-typography-{variant}-* e.g., --mui-typography-body1-fontSize
--{prefix}-zIndex-{component} e.g., --mui-zIndex-modal
| Mistake | Fix |
|---|---|
Using theme.palette.* in styled() with CssVarsProvider | Use theme.vars.palette.* for SSR-safe variable references |
Forgetting getInitColorSchemeScript() in SSR layout | Add it as the first child of <body> |
Checking mode before hydration causes mismatch | Guard with if (!mode) return null |
Using ThemeProvider with extendTheme() | Use CssVarsProvider — extendTheme is designed for it |
Using createTheme() with CssVarsProvider | Use extendTheme() — createTheme does not generate CSS vars |
Dynamic props in Pigment CSS styled() | Use CSS variables + inline style prop instead |
| Missing build plugin for Pigment CSS | css() and styled() from @pigment-css/react require the bundler plugin |