Agent Skills: MUI CSS Variables & Pigment CSS Skill

MUI CSS Variables mode (CssVarsProvider), Pigment CSS zero-runtime engine, and CSS custom properties theming

UncategorizedID: lobbi-docs/claude/css-variables-pigment

Install this agent skill to your local

pnpm dlx add-skill https://github.com/markus41/claude/tree/HEAD/plugins/mui-expert/skills/css-variables-pigment

Skill Files

Browse the full folder contents for css-variables-pigment.

Download Skill

Loading file tree…

plugins/mui-expert/skills/css-variables-pigment/SKILL.md

Skill Metadata

Name
css-variables-pigment
Description
MUI CSS Variables mode (CssVarsProvider), Pigment CSS zero-runtime engine, and CSS custom properties theming

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 + CssVarsProvider when:

    • 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 + ThemeProvider when:

    • Migrating from MUI v4/v5 and not ready to switch
    • Using third-party libraries that depend on ThemeProvider context
    • 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, css prop)
  • 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

  1. 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)' });
    
  2. Theme must be serializable: The theme passed to the plugin config must be plain JSON-serializable. No functions, class instances, or circular references.

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

  4. Emotion APIs not available: keyframes, Global, ClassNames, and the css prop from @emotion/react are not supported. Use @pigment-css/react equivalents.

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

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:

  1. Change import from @mui/material/styles to @pigment-css/react
  2. Replace theme.palette.* with theme.vars.palette.* in styled/css calls
  3. Move dynamic style logic to CSS variables + inline style prop
  4. Remove @emotion/react and @emotion/styled when fully migrated
  5. 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 CssVarsProviderextendTheme 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 |