MUI CSS Variables & Pigment CSS Skill
1. CssVarsProvider (MUI v6)
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.
Basic setup
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>
);
}
Key differences from ThemeProvider
| 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 |
2. extendTheme() vs createTheme()
createTheme()
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}>
extendTheme()
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}>
When to use which
-
Use
extendTheme+CssVarsProviderwhen:- You need SSR without flash-of-wrong-theme
- You want CSS-only dark mode toggling (no re-renders)
- You embed MUI components in non-React contexts (CSS vars work everywhere)
- You want to reference theme tokens in plain CSS files
- You are on MUI v6+
-
Use
createTheme+ThemeProviderwhen:- Migrating from MUI v4/v5 and not ready to switch
- Using third-party libraries that depend on
ThemeProvidercontext - Your app has a single color scheme and does not need SSR
3. colorSchemes Configuration
colorSchemes 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' },
},
},
},
});
Custom color schemes beyond light/dark
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');
Default color scheme
<CssVarsProvider theme={theme} defaultMode="dark">
{/* Renders with dark scheme initially */}
</CssVarsProvider>
Supported defaultMode values: 'light', 'dark', 'system' (follows OS preference).
4. SSR Flash Prevention with getInitColorSchemeScript()
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.
Next.js App Router (layout.tsx)
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>
);
}
Next.js Pages Router (_document.tsx)
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>
);
}
Configuration options
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
});
5. Runtime Color Scheme Switching
useColorScheme() hook
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>
);
}
Three-way toggle (light / dark / system)
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.
6. Accessing CSS Variables
In the sx prop
<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)',
}}
/>
In styled() components
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,
},
}));
In plain CSS / CSS Modules
/* 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 */
With custom cssVarPrefix
const theme = extendTheme({
cssVarPrefix: 'app',
// ...
});
// CSS variables are now:
// var(--app-palette-primary-main)
// var(--app-palette-background-default)
// var(--app-shape-borderRadius)
7. Theme Tokens as CSS Variables
How the mapping works
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) |
theme.vars vs theme direct access
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.
}));
Custom CSS variables
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)
8. Pigment CSS (Zero-Runtime Styling Engine)
What it is
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.
When to use Pigment CSS
- Server-heavy apps (Next.js App Router, RSC) where Emotion's runtime is a liability
- Performance-critical pages where eliminating the styling runtime reduces JS bundle size
- React Server Components: Emotion requires a client boundary; Pigment CSS does not
- Large design systems where static extraction improves cacheability
When NOT to use Pigment CSS
- Highly dynamic styles that depend on runtime state (e.g., user-dragged colors)
- Projects heavily invested in Emotion APIs (
keyframes,Global,cssprop) - Apps that need MUI v5 compatibility (Pigment CSS targets MUI v6+)
Setup with Next.js App Router
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',
},
},
});
Setup with Vite
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(),
],
});
Pigment CSS API: css() and styled()
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,
},
}));
Pigment CSS with variants
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,
}),
},
],
},
);
Limitations and gotchas
-
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()andstyled()calls are transformed at compile time by the plugin. -
Emotion APIs not available:
keyframes,Global,ClassNames, and thecssprop from@emotion/reactare not supported. Use@pigment-css/reactequivalents. -
Migration is incremental: You can use Pigment CSS alongside Emotion during migration. Components using
@pigment-css/reactare statically extracted; those still using@mui/material/stylesuse Emotion at runtime.
Migration path from Emotion
// 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:
- Change import from
@mui/material/stylesto@pigment-css/react - Replace
theme.palette.*withtheme.vars.palette.*in styled/css calls - Move dynamic style logic to CSS variables + inline
styleprop - Remove
@emotion/reactand@emotion/styledwhen fully migrated - Add the build plugin to your bundler config
9. TypeScript Augmentation for CSS Variables
Augmenting the theme with custom tokens
// 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;
};
};
}
}
Using augmented theme
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)
Augmenting component prop types for new palette colors
declare module '@mui/material/Button' {
interface ButtonPropsColorOverrides {
brand: true;
}
}
// Now <Button color="brand"> is type-safe
10. Complete Example: Full App Setup
// 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>
);
}
Quick Reference
Essential imports
// 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
CSS variable naming convention
--{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
Common mistakes
| 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 |