css-variables-pigment
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 |
More from lobbi-docs/claude
vision-multimodal
Vision and multimodal capabilities for Claude including image analysis, PDF processing, and document understanding. Activate for image input, base64 encoding, multiple images, and visual analysis.
248design-system
Apply and manage the AI-powered design system with 50+ curated styles
126complex-reasoning
Multi-step reasoning patterns and frameworks for systematic problem solving. Activate for Chain-of-Thought, Tree-of-Thought, hypothesis-driven debugging, and structured analytical approaches that leverage extended thinking.
106gcp
Google Cloud Platform services including GKE, Cloud Run, Cloud Storage, BigQuery, and Pub/Sub. Activate for GCP infrastructure, Google Cloud deployment, and GCP integration.
73kanban
Kanban methodology including boards, WIP limits, flow metrics, and continuous delivery. Activate for Kanban boards, workflow visualization, and lean project management.
63debugging
Debugging techniques for Python, JavaScript, and distributed systems. Activate for troubleshooting, error analysis, log investigation, and performance debugging. Includes extended thinking integration for complex debugging scenarios.
59