From 6790fc08046dbb8d0d9473eaa5df53337665afb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:29:01 +0000 Subject: [PATCH 01/20] [system][material-ui] Scope the CSS theme variables of nested and sibling ThemeProviders Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HG4YYGmgsLG7riohZnG1WZ --- packages/mui-material/src/Modal/Modal.js | 6 + packages/mui-material/src/Popper/Popper.tsx | 6 + .../mui-material/src/styles/ThemeProvider.tsx | 18 ++ .../src/styles/ThemeProviderScoped.test.tsx | 224 ++++++++++++++++++ .../src/styles/createThemeFoundation.ts | 9 + .../src/styles/createThemeWithVars.js | 4 +- packages/mui-material/src/styles/index.d.ts | 3 +- packages/mui-material/src/styles/index.js | 1 + .../src/cssVars/ThemeScopeContext.ts | 33 +++ .../src/cssVars/createCssVarsProvider.tsx | 202 ++++++++++++++-- .../src/cssVars/createCssVarsTheme.ts | 1 + packages/mui-system/src/cssVars/index.ts | 2 + .../mui-system/src/cssVars/prepareCssVars.ts | 68 +++--- .../src/cssVars/scopeCssVars.test.ts | 117 +++++++++ .../mui-system/src/cssVars/scopeCssVars.ts | 163 +++++++++++++ 15 files changed, 800 insertions(+), 57 deletions(-) create mode 100644 packages/mui-material/src/styles/ThemeProviderScoped.test.tsx create mode 100644 packages/mui-system/src/cssVars/ThemeScopeContext.ts create mode 100644 packages/mui-system/src/cssVars/scopeCssVars.test.ts create mode 100644 packages/mui-system/src/cssVars/scopeCssVars.ts diff --git a/packages/mui-material/src/Modal/Modal.js b/packages/mui-material/src/Modal/Modal.js index 2abe753d453182..febed1c4ddf008 100644 --- a/packages/mui-material/src/Modal/Modal.js +++ b/packages/mui-material/src/Modal/Modal.js @@ -5,6 +5,7 @@ import clsx from 'clsx'; import HTMLElementType from '@mui/utils/HTMLElementType'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; import composeClasses from '@mui/utils/composeClasses'; +import { useThemeScope } from '@mui/system/cssVars'; import FocusTrap from '../Unstable_TrapFocus'; import Portal from '../Portal'; import { styled } from '../zero-styled'; @@ -147,17 +148,22 @@ const Modal = React.forwardRef(function Modal(inProps, ref) { const externalForwardedProps = { slots, slotProps }; + // The modal is portaled out of a scoped ThemeProvider's element, so it applies the scope itself. + const { className: themeScopeClassName, ...themeScopeAttributes } = useThemeScope(); + const [RootSlot, rootProps] = useSlot('root', { ref, elementType: ModalRoot, externalForwardedProps: { ...externalForwardedProps, + ...themeScopeAttributes, ...other, component, }, getSlotProps: getRootProps, ownerState, className: clsx( + themeScopeClassName, className, classes?.root, !ownerState.open && ownerState.exited && classes?.hidden, diff --git a/packages/mui-material/src/Popper/Popper.tsx b/packages/mui-material/src/Popper/Popper.tsx index 5552227c2920aa..e996d68ddab99d 100644 --- a/packages/mui-material/src/Popper/Popper.tsx +++ b/packages/mui-material/src/Popper/Popper.tsx @@ -1,6 +1,8 @@ 'use client'; import { SxProps } from '@mui/system'; import { useRtl } from '@mui/system/RtlProvider'; +import { useThemeScope } from '@mui/system/cssVars'; +import clsx from 'clsx'; import refType from '@mui/utils/refType'; import HTMLElementType from '@mui/utils/HTMLElementType'; import PropTypes from 'prop-types'; @@ -45,6 +47,8 @@ const Popper = React.forwardRef(function Popper( ref: React.ForwardedRef, ) { const isRtl = useRtl(); + // The popper is portaled out of a scoped ThemeProvider's element, so it applies the scope itself. + const { className: themeScopeClassName, ...themeScopeAttributes } = useThemeScope(); const props = useDefaultProps({ props: inProps, name: 'MuiPopper', @@ -86,7 +90,9 @@ const Popper = React.forwardRef(function Popper( direction={isRtl ? 'rtl' : 'ltr'} slots={slots} slotProps={slotProps} + {...themeScopeAttributes} {...otherProps} + className={clsx(themeScopeClassName, otherProps.className) || undefined} ref={ref} /> ); diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index b3f45c0f65f267..7c297702ed97d1 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -32,6 +32,24 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true * @default false */ forceThemeRerender?: boolean | undefined; + /** + * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`, + * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, + * like an embedded widget. Nested providers are scoped automatically when their variables differ. + * + * A string is used as a readable part of the class name, and to suffix the storage keys. + * @default false + */ + scoped?: boolean | string | undefined; + /** + * The element that wraps the children of a scoped provider. + * By default, a `div` with `display: contents`. + */ + component?: React.ElementType | undefined; + /** + * The class name of the element that wraps the children of a scoped provider. + */ + className?: string | undefined; } : {}; diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx new file mode 100644 index 00000000000000..3ea187353b48f7 --- /dev/null +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import * as React from 'react'; +import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import Popper from '@mui/material/Popper'; +import { ThemeProvider, createTheme, useThemeScope } from '@mui/material/styles'; + +function getVar(element: Element, name: string) { + return getComputedStyle(element).getPropertyValue(name).trim(); +} + +const blueTheme = createTheme({ cssVariables: true, palette: { primary: { main: '#0000ff' } } }); +const redTheme = createTheme({ cssVariables: true, palette: { primary: { main: '#ff0000' } } }); + +describe('ThemeProvider with scoped CSS theme variables', () => { + const { render } = createRenderer(); + + describe('nested', () => { + it('wraps the children in a scoped element when the variables differ', () => { + render( + +
+ +
+ +
+
, + ); + const wrapper = screen.getByTestId('inner').parentElement!; + expect(wrapper).not.to.equal(screen.getByTestId('outer')); + expect(wrapper.className).to.match(/^mui-theme-[a-z0-9]+$/); + expect(wrapper.style.display).to.equal('contents'); + }); + + it('renders no element when the theme is the same', () => { + render( + +
+ +
+ +
+
, + ); + expect(screen.getByTestId('inner').parentElement).to.equal(screen.getByTestId('outer')); + }); + + it('renders no element when the theme only changes component defaults', () => { + const theme = createTheme({ + cssVariables: true, + palette: { primary: { main: '#0000ff' } }, + components: { MuiButton: { defaultProps: { disableRipple: true } } }, + }); + render( + +
+ +
+ +
+
, + ); + expect(screen.getByTestId('inner').parentElement).to.equal(screen.getByTestId('outer')); + }); + + it.skipIf(isJsdom())('applies the inner variables only to the children', () => { + render( + + + + + + , + ); + expect(getVar(screen.getByTestId('outer'), '--mui-palette-primary-main')).to.equal('#0000ff'); + expect(getVar(screen.getByTestId('inner'), '--mui-palette-primary-main')).to.equal('#ff0000'); + expect(getComputedStyle(screen.getByTestId('inner')).backgroundColor).to.equal( + 'rgb(255, 0, 0)', + ); + }); + + it.skipIf(isJsdom())('applies the inner variables to portaled dialogs and poppers', () => { + function Anchored() { + const [anchorEl, setAnchorEl] = React.useState(null); + return ( + +
+ + popper + + + ); + } + render( + + + +
+
+ +
+
, + ); + expect(getVar(screen.getByTestId('dialog-content'), '--mui-palette-primary-main')).to.equal( + '#ff0000', + ); + expect(getVar(screen.getByTestId('popper'), '--mui-palette-primary-main')).to.equal( + '#ff0000', + ); + }); + + it.skipIf(isJsdom())('follows the outer color scheme', () => { + const outer = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, + }); + const inner = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { + light: { palette: { primary: { main: '#ff0000' } } }, + dark: { palette: { primary: { main: '#00ff00' } } }, + }, + }); + render( + + +
+ + , + ); + expect(document.documentElement.classList.contains('dark')).to.equal(true); + expect(getVar(screen.getByTestId('inner'), '--mui-palette-primary-main')).to.equal('#00ff00'); + document.documentElement.classList.remove('dark'); + }); + }); + + describe('scoped prop', () => { + it.skipIf(isJsdom())('keeps a sibling provider off :root', () => { + const darkTheme = createTheme({ + cssVariables: true, + palette: { mode: 'dark', primary: { main: '#ff0000' } }, + }); + render( + + +
+ + +
+ + , + ); + expect(getVar(document.documentElement, '--mui-palette-primary-main')).to.equal('#0000ff'); + expect(getComputedStyle(document.documentElement).colorScheme).not.to.equal('dark'); + expect(getVar(screen.getByTestId('app'), '--mui-palette-primary-main')).to.equal('#0000ff'); + expect(getVar(screen.getByTestId('widget'), '--mui-palette-primary-main')).to.equal( + '#ff0000', + ); + expect(getComputedStyle(screen.getByTestId('widget')).colorScheme).to.equal('dark'); + }); + + it('uses the name in the class name', () => { + render( + +
+ , + ); + expect(screen.getByTestId('widget').parentElement!.className).to.match( + /^mui-theme-widget-[a-z0-9]+$/, + ); + }); + + it('warns when two providers with different themes write to :root', () => { + expect(() => + render( + + +
+ + +
+ + , + ), + ).toWarnDev('MUI: Several ThemeProviders with different themes write'); + }); + }); + + it('renders the `component` with the scope class', () => { + render( + + +
+ + , + ); + const section = screen.getByTestId('inner').parentElement!; + expect(section.tagName).to.equal('SECTION'); + expect(section.className).to.match(/^mui-theme-[a-z0-9]+ promo$/); + expect(section.style.display).to.equal(''); + }); + + it('exposes the scope class to elements that are portaled by other libraries', () => { + function Scope() { + const props = useThemeScope(); + return
; + } + render( + + + + + , + ); + expect(screen.getByTestId('scope').className).to.equal( + screen.getByTestId('scope').parentElement!.className, + ); + }); +}); diff --git a/packages/mui-material/src/styles/createThemeFoundation.ts b/packages/mui-material/src/styles/createThemeFoundation.ts index 5583e386d9f7cd..84adde75ff1a18 100644 --- a/packages/mui-material/src/styles/createThemeFoundation.ts +++ b/packages/mui-material/src/styles/createThemeFoundation.ts @@ -421,6 +421,15 @@ export interface CssVarsTheme extends ColorSystem { getColorSchemeSelector: (colorScheme: SupportedColorScheme) => string; generateThemeVars: () => ThemeVars; generateStyleSheets: () => Array>; + /** + * @internal Used by ThemeProvider to scope the CSS theme variables of nested themes. + */ + generateCssVarsByColorScheme?: + | (() => { + root: Record; + colorSchemes: Array<[string, Record]>; + }) + | undefined; generateSpacing: () => SystemTheme['spacing']; // Default theme tokens diff --git a/packages/mui-material/src/styles/createThemeWithVars.js b/packages/mui-material/src/styles/createThemeWithVars.js index bd00b44d4c4ba7..84bac4ab98d64a 100644 --- a/packages/mui-material/src/styles/createThemeWithVars.js +++ b/packages/mui-material/src/styles/createThemeWithVars.js @@ -1010,13 +1010,15 @@ export default function createThemeWithVars(options = {}, ...args) { getSelector: defaultGetSelector(theme), enableContrastVars: nativeColor, }; - const { vars, generateThemeVars, generateStyleSheets } = prepareCssVars(theme, parserConfig); + const { vars, generateThemeVars, generateStyleSheets, generateCssVarsByColorScheme } = + prepareCssVars(theme, parserConfig); theme.vars = vars; Object.entries(theme.colorSchemes[theme.defaultColorScheme]).forEach(([key, value]) => { theme[key] = value; }); theme.generateThemeVars = generateThemeVars; theme.generateStyleSheets = generateStyleSheets; + theme.generateCssVarsByColorScheme = generateCssVarsByColorScheme; theme.generateSpacing = function generateSpacing() { return createSpacing(input.spacing, createUnarySpacing(this)); }; diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index d5c9d62233011d..2c2ff2eafe937b 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -127,7 +127,8 @@ export { default as withStyles } from './withStyles'; export { default as withTheme } from './withTheme'; export * from './ThemeProviderWithVars'; -export type { StorageManager } from '@mui/system/cssVars'; +export type { StorageManager, ThemeScopeProps } from '@mui/system/cssVars'; +export { useThemeScope } from '@mui/system/cssVars'; export { default as extendTheme } from './createThemeWithVars'; diff --git a/packages/mui-material/src/styles/index.js b/packages/mui-material/src/styles/index.js index 883093f0e1e25b..5c6a0660e0a34f 100644 --- a/packages/mui-material/src/styles/index.js +++ b/packages/mui-material/src/styles/index.js @@ -44,6 +44,7 @@ export { default as withStyles } from './withStyles'; export { default as withTheme } from './withTheme'; export * from './ThemeProviderWithVars'; +export { useThemeScope } from '@mui/system/cssVars'; export { default as extendTheme } from './createThemeWithVars'; export { default as experimental_extendTheme } from './experimental_extendTheme'; // TODO: Remove in v7 export { default as getOverlayAlpha } from './getOverlayAlpha'; diff --git a/packages/mui-system/src/cssVars/ThemeScopeContext.ts b/packages/mui-system/src/cssVars/ThemeScopeContext.ts new file mode 100644 index 00000000000000..a40521fcf66c2d --- /dev/null +++ b/packages/mui-system/src/cssVars/ThemeScopeContext.ts @@ -0,0 +1,33 @@ +'use client'; +import * as React from 'react'; + +export interface ThemeScopeProps { + /** + * The class that sets the CSS theme variables of the closest scoped `ThemeProvider`, + * followed by its color scheme class when its `colorSchemeSelector` is `'class'`. + */ + className?: string | undefined; + /** + * Color scheme attributes, e.g. `data-mui-color-scheme`, when the `colorSchemeSelector` uses one. + */ + [attribute: `data-${string}`]: string | undefined; +} + +const ThemeScopeContext = React.createContext(undefined); + +if (process.env.NODE_ENV !== 'production') { + ThemeScopeContext.displayName = 'ThemeScopeContext'; +} + +const emptyProps: ThemeScopeProps = {}; + +/** + * Returns the props that apply the closest scoped `ThemeProvider` to an element. + * Portaled elements render outside the provider's element, so they need these props + * to use its CSS theme variables. Returns an empty object outside scoped providers. + */ +export function useThemeScope(): ThemeScopeProps { + return React.useContext(ThemeScopeContext) ?? emptyProps; +} + +export default ThemeScopeContext; diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index bfad6a75368bdd..cd0878d3a73ba6 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import clsx from 'clsx'; import { GlobalStyles } from '@mui/styled-engine'; import { useTheme as muiUseTheme } from '@mui/private-theming'; import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; @@ -13,6 +14,13 @@ import { } from '../InitColorSchemeScript/InitColorSchemeScript'; import useCurrentColorScheme, { type Result } from './useCurrentColorScheme'; import type { StorageManager } from './localStorageManager'; +import ThemeScopeContext from './ThemeScopeContext'; +import { + createScopedStyleSheets, + getColorSchemeProps, + getColorSchemeRule, + type CssVarsByColorScheme, +} from './scopeCssVars'; export interface ColorSchemeContextValue< SupportedColorScheme extends string, @@ -112,6 +120,24 @@ export interface CreateCssVarsProviderResult< * @default false */ disableStyleSheetGeneration?: boolean | undefined; + /** + * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`, + * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, + * like an embedded widget. Nested providers are scoped automatically when their variables differ. + * + * A string is used as a readable part of the class name, and to suffix the storage keys. + * @default false + */ + scoped?: boolean | string | undefined; + /** + * The element that wraps the children of a scoped provider. + * By default, a `div` with `display: contents`. + */ + component?: React.ElementType | undefined; + /** + * The class name of the element that wraps the children of a scoped provider. + */ + className?: string | undefined; } >, ) => React.JSX.Element; @@ -211,13 +237,19 @@ export default function createCssVarsProvider< const defaultColorSchemes = {}; const defaultComponents = {}; + const scopeStyle = { display: 'contents' }; + + // Stylesheets of the mounted providers that write to `:root`, to warn when they overwrite each other. + const rootStyleSheets: Map = new Map(); + let warnedMultipleRoots = false; function CssVarsProvider(props: any) { const { children, theme: themeProp, - modeStorageKey = defaultModeStorageKey, - colorSchemeStorageKey = defaultColorSchemeStorageKey, + scoped = false, + component, + className, disableTransitionOnChange = designSystemTransitionOnChange, storageManager, storageWindow = typeof window === 'undefined' ? undefined : window, @@ -230,9 +262,18 @@ export default function createCssVarsProvider< noSsr, } = props; const hasMounted = React.useRef(false); - const upperTheme = muiUseTheme(); + const upperTheme = muiUseTheme() as any; const ctx = React.useContext(ColorSchemeContext); + const upperScope = React.useContext(ThemeScopeContext); const nested = !!ctx && !disableNestedContext; + const scopeName = typeof scoped === 'string' ? scoped : undefined; + // A scoped provider that isn't nested manages its own color scheme on its element. + const isScopedRoot = !nested && !!scoped; + const storageKeySuffix = isScopedRoot && scopeName ? `-${scopeName}` : ''; + const { + modeStorageKey = `${defaultModeStorageKey}${storageKeySuffix}`, + colorSchemeStorageKey = `${defaultColorSchemeStorageKey}${storageKeySuffix}`, + } = props; const initialTheme = React.useMemo(() => { if (themeProp) { @@ -350,26 +391,18 @@ export default function createCssVarsProvider< // 5. Declaring effects // 5.1 Updates the selector value to use the current color scheme which tells CSS to use the proper stylesheet. + // A scoped root renders it on its own element instead. const colorSchemeSelector = restThemeProp.colorSchemeSelector; + const ownsColorSchemeNode = !isScopedRoot || props.colorSchemeNode !== undefined; useEnhancedEffect(() => { if ( + ownsColorSchemeNode && colorScheme && colorSchemeNode && colorSchemeSelector && colorSchemeSelector !== 'media' ) { - const selector = colorSchemeSelector; - let rule = colorSchemeSelector; - if (selector === 'class') { - rule = `.%s`; - } - if (selector === 'data') { - rule = `[data-%s]`; - } - if (selector?.startsWith('data-') && !selector.includes('%s')) { - // 'data-mui-color-scheme' -> '[data-mui-color-scheme="%s"]' - rule = `[${selector}="%s"]`; - } + const rule = getColorSchemeRule(colorSchemeSelector)!; if (rule.startsWith('.')) { colorSchemeNode.classList.remove( ...allColorSchemes.map((scheme) => rule.substring(1).replace('%s', scheme)), @@ -392,7 +425,7 @@ export default function createCssVarsProvider< } } } - }, [colorScheme, colorSchemeSelector, colorSchemeNode, allColorSchemes]); + }, [colorScheme, colorSchemeSelector, colorSchemeNode, allColorSchemes, ownsColorSchemeNode]); // 5.2 Remove the CSS transition when color scheme changes to create instant experience. // credit: https://github.com/pacocoursey/next-themes/blob/b5c2bad50de2d61ad7b52a9c5cdc801a78507d7a/index.tsx#L313 @@ -460,16 +493,105 @@ export default function createCssVarsProvider< ], ); - let shouldGenerateStyleSheet = true; - if ( - disableStyleSheetGeneration || - restThemeProp.cssVariables === false || - (nested && (upperTheme as any)?.cssVarPrefix === cssVarPrefix) - ) { - shouldGenerateStyleSheet = false; + const canGenerateStyleSheet = + !disableStyleSheetGeneration && restThemeProp.cssVariables !== false; + const samePrefixAsUpper = nested && upperTheme?.cssVarPrefix === cssVarPrefix; + + // 6. Scope the CSS theme variables that can't go to `:root` to the provider's element. + const generateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = + restThemeProp.generateCssVarsByColorScheme; + const upperGenerateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = + samePrefixAsUpper ? upperTheme.generateCssVarsByColorScheme : undefined; + const upperColorSchemeSelector = nested ? upperTheme?.colorSchemeSelector : undefined; + const scope = React.useMemo(() => { + if (!canGenerateStyleSheet || !generateCssVarsByColorScheme) { + return null; + } + if (nested) { + // A nested theme with another prefix doesn't clash with the outer one, so it writes to `:root`. + if (!upperGenerateCssVarsByColorScheme) { + return null; + } + return createScopedStyleSheets({ + css: generateCssVarsByColorScheme(), + upper: upperGenerateCssVarsByColorScheme(), + // It follows the outer color scheme. + colorSchemeSelector: upperColorSchemeSelector, + prefix: cssVarPrefix, + name: scopeName, + }); + } + if (scoped) { + return createScopedStyleSheets({ + css: generateCssVarsByColorScheme(), + colorSchemeSelector, + prefix: cssVarPrefix, + name: scopeName, + }); + } + return null; + }, [ + canGenerateStyleSheet, + generateCssVarsByColorScheme, + upperGenerateCssVarsByColorScheme, + upperColorSchemeSelector, + colorSchemeSelector, + cssVarPrefix, + nested, + scoped, + scopeName, + ]); + + const scopeClassName = scope?.className; + const { className: colorSchemeClassName, ...colorSchemeAttributes } = isScopedRoot + ? getColorSchemeProps(colorSchemeSelector, colorScheme) + : ({} as Record); + const colorSchemeAttributesKey = JSON.stringify(colorSchemeAttributes); + const scopeProps = React.useMemo(() => { + if (!scopeClassName) { + return undefined; + } + // Portaled elements aren't under the outer scopes' elements either, so they get them too. + return { + ...(nested ? upperScope : undefined), + ...JSON.parse(colorSchemeAttributesKey), + className: clsx(nested && upperScope?.className, scopeClassName, colorSchemeClassName), + }; + }, [scopeClassName, colorSchemeClassName, colorSchemeAttributesKey, nested, upperScope]); + + const shouldGenerateStyleSheet = canGenerateStyleSheet && !samePrefixAsUpper && !isScopedRoot; + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (!shouldGenerateStyleSheet || nested || !memoTheme.generateStyleSheets) { + return undefined; + } + const key = cssVarPrefix ?? ''; + const styleSheet = JSON.stringify(memoTheme.generateStyleSheets()); + const mounted = rootStyleSheets.get(key) ?? []; + rootStyleSheets.set(key, [...mounted, styleSheet]); + if (!warnedMultipleRoots && mounted.some((other) => other !== styleSheet)) { + warnedMultipleRoots = true; + console.warn( + [ + 'MUI: Several ThemeProviders with different themes write their CSS theme variables to `:root`, so they overwrite each other.', + 'Add the `scoped` prop to the ThemeProviders that should only apply to their children, like an embedded widget.', + ].join('\n'), + ); + } + return () => { + const current = rootStyleSheets.get(key) ?? []; + const index = current.indexOf(styleSheet); + rootStyleSheets.set( + key, + current.filter((_, i) => i !== index), + ); + }; + }, [shouldGenerateStyleSheet, nested, memoTheme, cssVarPrefix]); } - const element = ( + let element = ( {children} @@ -480,6 +602,26 @@ export default function createCssVarsProvider< ); + if (scope || component) { + const ScopeComponent = component || 'div'; + element = ( + + {scope && } + + {scopeProps ? ( + {element} + ) : ( + element + )} + + + ); + } + if (nested) { return element; } @@ -494,6 +636,10 @@ export default function createCssVarsProvider< * The component tree. */ children: PropTypes.node, + /** + * The class name of the element that wraps the children of a scoped provider. + */ + className: PropTypes.string, /** * The node used to attach the color-scheme attribute */ @@ -502,6 +648,10 @@ export default function createCssVarsProvider< * localStorage key used to store `colorScheme` */ colorSchemeStorageKey: PropTypes.string, + /** + * The element that wraps the children of a scoped provider. + */ + component: PropTypes.elementType, /** * The default mode when the storage is empty, * require the theme to have `colorSchemes` with light and dark. @@ -543,6 +693,10 @@ export default function createCssVarsProvider< * @default using `window.localStorage` */ storageManager: PropTypes.func, + /** + * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`. + */ + scoped: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** * The window that attaches the 'storage' event listener. * @default window diff --git a/packages/mui-system/src/cssVars/createCssVarsTheme.ts b/packages/mui-system/src/cssVars/createCssVarsTheme.ts index 03a5c73e7630d4..85139ea7da762e 100644 --- a/packages/mui-system/src/cssVars/createCssVarsTheme.ts +++ b/packages/mui-system/src/cssVars/createCssVarsTheme.ts @@ -25,6 +25,7 @@ function createCssVarsTheme | undefined; defaultColorScheme?: string | undefined; @@ -98,9 +105,34 @@ function prepareCssVars { + const colorScheme = theme.defaultColorScheme || 'light'; + const schemes: Array<[string, Record]> = []; + const { [colorScheme]: defaultSchemeVal, ...other } = colorSchemesMap; + const entries = defaultSchemeVal + ? [[colorScheme, defaultSchemeVal] as const, ...Object.entries(other)] + : Object.entries(other); + entries.forEach(([key, { css }]) => { + const cssColorSheme = colorSchemes[key]?.palette?.mode; + schemes.push([ + key, + !disableCssColorScheme && cssColorSheme + ? { colorScheme: cssColorSheme, ...css } + : { ...css }, + ]); + }); + return { + root: enableContrastVars ? { ...rootCss, ...CONTRAST_VARS } : { ...rootCss }, + colorSchemes: schemes, + }; + }; + const generateStyleSheets = () => { const stylesheets: Array> = []; - const colorScheme = theme.defaultColorScheme || 'light'; function insertStyleSheet(key: string | object, css: Record) { if (Object.keys(css).length) { stylesheets.push(typeof key === 'string' ? { [key]: { ...css } } : key); @@ -108,40 +140,13 @@ function prepareCssVars { - const cssColorSheme = colorSchemes[key]?.palette?.mode; - const finalCss = - !disableCssColorScheme && cssColorSheme - ? { colorScheme: cssColorSheme, ...css } - : { ...css }; + // default color scheme has to come before other color schemes + generateCssVarsByColorScheme().colorSchemes.forEach(([key, finalCss]) => { insertStyleSheet(getSelector(key as keyof T['colorSchemes'], { ...finalCss }), finalCss); }); if (enableContrastVars) { - stylesheets.push({ - ':root': { - // use double underscore to indicate that these are private variables - '--__l-threshold': '0.7', - '--__l': 'clamp(0, (l / var(--__l-threshold) - 1) * -infinity, 1)', - '--__a': 'clamp(0.87, (l / var(--__l-threshold) - 1) * -infinity, 1)', // 0.87 is the default alpha value for black text. - }, - }); + stylesheets.push({ ':root': { ...CONTRAST_VARS } }); } return stylesheets; @@ -151,6 +156,7 @@ function prepareCssVars]>, +}; + +describe('createScopedStyleSheets', () => { + it('returns null when a nested theme has the same declarations', () => { + expect( + createScopedStyleSheets({ css: upper, upper, colorSchemeSelector: 'class', prefix: 'mui' }), + ).to.equal(null); + }); + + it('writes only the declarations that differ from the outer theme', () => { + const result = createScopedStyleSheets({ + css: { + root: { '--mui-spacing': '8px' }, + colorSchemes: [ + ['light', { colorScheme: 'light', '--mui-primary': 'red', '--mui-text': 'black' }], + ['dark', { colorScheme: 'dark', '--mui-primary': 'pink', '--mui-text': 'white' }], + ], + }, + upper, + colorSchemeSelector: 'class', + prefix: 'mui', + })!; + const scope = `.${result.className}`; + expect(result.className).to.match(/^mui-theme-[a-z0-9]+$/); + expect(result.styles).to.deep.equal([ + { [scope]: { '--mui-primary': 'red' } }, + { [`.dark ${scope}, ${scope}.dark`]: { '--mui-primary': 'pink' } }, + ]); + }); + + it('writes the default color scheme declarations again for the other color schemes', () => { + // Only the light primary changes, but `.scope` applies in dark mode too. + const result = createScopedStyleSheets({ + css: { + root: upper.root, + colorSchemes: [ + ['light', { ...upper.colorSchemes[0][1], '--mui-primary': 'red' }], + upper.colorSchemes[1], + ], + }, + upper, + colorSchemeSelector: 'class', + prefix: 'mui', + })!; + const scope = `.${result.className}`; + expect(result.styles).to.deep.equal([ + { [scope]: { '--mui-primary': 'red' } }, + { [`.dark ${scope}, ${scope}.dark`]: { '--mui-primary': 'lightblue' } }, + ]); + }); + + it('writes a single color scheme in full, whatever the outer color scheme is', () => { + const result = createScopedStyleSheets({ + css: { root: upper.root, colorSchemes: [upper.colorSchemes[1]] }, + upper, + colorSchemeSelector: 'class', + prefix: 'mui', + })!; + expect(result.styles).to.deep.equal([{ [`.${result.className}`]: upper.colorSchemes[1][1] }]); + }); + + it('uses media queries with the media selector', () => { + const result = createScopedStyleSheets({ + css: upper, + colorSchemeSelector: 'media', + prefix: 'mui', + })!; + const scope = `.${result.className}`; + expect(result.styles).to.deep.equal([ + { [scope]: upper.root }, + { [scope]: upper.colorSchemes[0][1] }, + { '@media (prefers-color-scheme: dark)': { [scope]: upper.colorSchemes[1][1] } }, + ]); + }); + + it('puts the color scheme rule on the scoped element when it is not nested', () => { + const result = createScopedStyleSheets({ + css: upper, + colorSchemeSelector: 'data-mui-color-scheme', + prefix: 'mui', + name: 'widget', + })!; + const scope = `.${result.className}`; + expect(result.className).to.match(/^mui-theme-widget-[a-z0-9]+$/); + expect(result.styles[2]).to.deep.equal({ + [`${scope}[data-mui-color-scheme="dark"]`]: upper.colorSchemes[1][1], + }); + }); + + it('gives the same class name to the same declarations', () => { + const options = { css: upper, colorSchemeSelector: 'class', prefix: 'mui' }; + expect(createScopedStyleSheets(options)!.className).to.equal( + createScopedStyleSheets({ ...options, css: structuredClone(upper) })!.className, + ); + }); +}); + +describe('getColorSchemeProps', () => { + it('returns the class or attribute of a color scheme', () => { + expect(getColorSchemeProps('class', 'dark')).to.deep.equal({ className: 'dark' }); + expect(getColorSchemeProps('data', 'dark')).to.deep.equal({ 'data-dark': '' }); + expect(getColorSchemeProps('data-mui-color-scheme', 'dark')).to.deep.equal({ + 'data-mui-color-scheme': 'dark', + }); + expect(getColorSchemeProps('media', 'dark')).to.deep.equal({}); + expect(getColorSchemeProps('class', undefined)).to.deep.equal({}); + }); +}); diff --git a/packages/mui-system/src/cssVars/scopeCssVars.ts b/packages/mui-system/src/cssVars/scopeCssVars.ts new file mode 100644 index 00000000000000..e7e4b0a3145fc9 --- /dev/null +++ b/packages/mui-system/src/cssVars/scopeCssVars.ts @@ -0,0 +1,163 @@ +type CssDeclarations = Record; + +export interface CssVarsByColorScheme { + root: CssDeclarations; + colorSchemes: Array<[string, CssDeclarations]>; +} + +/** + * Turns `theme.colorSchemeSelector` into a rule with a `%s` placeholder for the color scheme, + * or `'media'`. Returns `undefined` when there's no selector. + */ +export function getColorSchemeRule(selector: string | undefined) { + if (selector === 'class') { + return '.%s'; + } + if (selector === 'data') { + return '[data-%s]'; + } + if (selector?.startsWith('data-') && !selector.includes('%s')) { + // 'data-mui-color-scheme' -> '[data-mui-color-scheme="%s"]' + return `[${selector}="%s"]`; + } + return selector; +} + +/** + * Returns the props that make an element match the color scheme rule, + * e.g. `{ className: 'dark' }` for `.%s` or `{ 'data-mui-color-scheme': 'dark' }`. + */ +export function getColorSchemeProps( + selector: string | undefined, + colorScheme: string | undefined, +): Record { + const rule = getColorSchemeRule(selector); + if (!rule || rule === 'media' || !colorScheme) { + return {}; + } + if (rule.startsWith('.')) { + return { className: rule.substring(1).replace('%s', colorScheme) }; + } + const matches = rule.replace('%s', colorScheme).match(/\[([^\]]+)\]/); + if (!matches) { + return {}; + } + const [attr, value] = matches[1].split('='); + return { [attr]: value ? value.replace(/"|'/g, '') : '' }; +} + +// FNV-1a, enough to give each set of declarations a short and stable class name. +/* eslint-disable no-bitwise */ +function hash(value: string) { + let result = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + result ^= value.charCodeAt(i); + result = Math.imul(result, 0x01000193); + } + return (result >>> 0).toString(36); +} +/* eslint-enable no-bitwise */ + +function pickChanged(css: CssDeclarations, upperCss: CssDeclarations | undefined) { + if (!upperCss) { + return { ...css }; + } + const result: CssDeclarations = {}; + Object.keys(css).forEach((key) => { + if (css[key] !== upperCss[key]) { + result[key] = css[key]; + } + }); + return result; +} + +/** + * Builds the stylesheets that set the CSS theme variables of a theme on a class instead of `:root`, + * so that only the elements under that class use them. + * + * - With `upper`, the theme is nested: only the declarations that differ from `upper` are written, + * and the color scheme follows the outer provider, whose rule is on an ancestor (`.dark .scope`) + * or, for portaled elements, on the same element (`.scope.dark`). + * - Without `upper`, all declarations are written, and the color scheme rule is on the scoped element + * itself (`.scope.dark`), since the provider manages its own color scheme. + * + * Returns `null` when there's nothing to write. + */ +export function createScopedStyleSheets(options: { + css: CssVarsByColorScheme; + upper?: CssVarsByColorScheme | undefined; + colorSchemeSelector: string | undefined; + prefix: string; + name?: string | undefined; +}) { + const { css, upper, colorSchemeSelector, prefix, name } = options; + const rule = getColorSchemeRule(colorSchemeSelector); + const [defaultScheme, ...otherSchemes] = css.colorSchemes; + const upperSchemes = upper ? Object.fromEntries(upper.colorSchemes) : {}; + // A theme with a single color scheme uses it whatever the outer color scheme is, + // so unless the outer theme has a single color scheme too, it's written in full. + const isForcedScheme = css.colorSchemes.length === 1 && (upper?.colorSchemes.length ?? 0) > 1; + + const root = pickChanged(css.root, upper?.root); + const schemes: Array<[string, CssDeclarations]> = []; + if (defaultScheme) { + const [key, declarations] = defaultScheme; + const changed = pickChanged(declarations, isForcedScheme ? undefined : upperSchemes[key]); + schemes.push([key, changed]); + otherSchemes.forEach(([otherKey, otherDeclarations]) => { + const otherChanged = pickChanged(otherDeclarations, upperSchemes[otherKey]); + // The default color scheme's rule applies in every color scheme, so each declaration + // it writes has to be written again for the other color schemes. + Object.keys(changed).forEach((declaration) => { + if (declaration in otherDeclarations) { + otherChanged[declaration] = otherDeclarations[declaration]; + } + }); + schemes.push([otherKey, otherChanged]); + }); + } + + const isEmpty = + Object.keys(root).length === 0 && + schemes.every(([, declarations]) => Object.keys(declarations).length === 0); + if (isEmpty) { + return null; + } + + const className = `${prefix ? `${prefix}-` : ''}theme-${name ? `${name}-` : ''}${hash( + JSON.stringify([root, schemes, rule, !!upper]), + )}`; + const scope = `.${className}`; + + function getSelector(colorScheme: string) { + if (!rule) { + return scope; + } + const colorSchemeRule = rule.replace('%s', colorScheme); + // A portaled element gets the outer scopes' classes too, so the color scheme rule + // can also be on the scoped element itself. + return upper + ? `${colorSchemeRule} ${scope}, ${scope}${colorSchemeRule}` + : `${scope}${colorSchemeRule}`; + } + + const styles: Array> = []; + if (Object.keys(root).length) { + styles.push({ [scope]: root }); + } + schemes.forEach(([key, declarations], index) => { + if (!Object.keys(declarations).length) { + return; + } + if (index === 0) { + styles.push({ [scope]: declarations }); + } else if (rule === 'media') { + const mode = Object.fromEntries(css.colorSchemes)[key].colorScheme || key; + styles.push({ [`@media (prefers-color-scheme: ${mode})`]: { [scope]: declarations } }); + } else { + styles.push({ [getSelector(key)]: declarations }); + } + }); + + return { className, styles }; +} From b8da872ed1a3122b207568dabe687fbd3cc89117 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:48:29 +0000 Subject: [PATCH 02/20] [docs] Document nested and scoped ThemeProviders with CSS theme variables Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HG4YYGmgsLG7riohZnG1WZ --- .../css-theme-variables/configuration.md | 72 +++++++++++++++++++ .../material/customization/theming/theming.md | 3 + 2 files changed, 75 insertions(+) diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index af198b2064d4ae..bf425659ddbb84 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -277,6 +277,78 @@ In the example below, all the components inside the `div` will always be dark: +## Nested and multiple theme providers + +The first `ThemeProvider` writes the CSS theme variables to `:root`, so they apply to the whole page. +Any other provider writes the same variables on an element that wraps its children, so they only apply there. + +### Nested providers + +A nested `ThemeProvider` whose variables differ from the outer theme wraps its children in a `div` with `display: contents` and a generated class. +The class only sets the variables that differ, and it follows the outer light or dark mode: + +```jsx + + + + + + +``` + +```css +:root { + --mui-palette-primary-main: #1976d2; +} +.mui-theme-1x2y3z { + --mui-palette-primary-main: #9c27b0; +} +``` + +A nested provider with the same variables, for example one that only changes `components` defaults, renders no element. + +Dialogs, menus, popovers, tooltips, and other components that render in a portal apply the class to their own root, so they use the nested theme too. +For elements that other libraries portal, use the `useThemeScope()` hook to get the class: + +```jsx +import { useThemeScope } from '@mui/material/styles'; + +function Toast() { + const themeScope = useThemeScope(); + return ReactDOM.createPortal(
…
, document.body); +} +``` + +To put the class on your own element instead of the `div`, for example inside a table or a list, where a `div` isn't valid HTML, use the `component` prop: + +```jsx + + {rows} + +``` + +### Sibling providers + +A `ThemeProvider` that isn't nested in another one also writes to `:root`, so two of them overwrite each other. +Add the `scoped` prop to the one that should only apply to its children, like an embedded widget: + +```jsx + + + + + + +``` + +A scoped provider writes all of its variables on its element, and has its own light or dark mode. +When `scoped` is a string, it's added to the class name and to the storage keys of the mode, for example `mui-mode-widget`. + +:::info +A CSS variable that you override on `:root` doesn't reach the elements under a scoped provider that sets it, because the closest declaration wins. +Override it on the scoped element instead, for example with the `className` prop. +::: + ## Disabling CSS color scheme By default, `createTheme()` attaches a [CSS `color-scheme` property](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme) based on the palette mode. diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index 5e3481df0516ce..3c657097ea1c78 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,6 +125,9 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} +With CSS theme variables, a nested theme sets its variables on an element that wraps its children. +See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. + ## CSS theme variables To generate CSS variables from the theme, set `cssVariables` to `true` in the theme configuration and pass it to the `ThemeProvider`: From 39577d074533bd807a4900519732e4b87faa7166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 14:56:08 +0100 Subject: [PATCH 03/20] [docs][system] Polish the scoped ThemeProvider docs and drop an unneeded type - Fix the intro of "Nested and multiple theme providers": sibling providers also write to `:root`, only nested or `scoped` ones are scoped. - Mention the dev warning for sibling providers, and that a `scoped` name suffixes both storage keys. - Note in the theming guide that only nested themes whose variables differ are wrapped. - Remove the internal `generateCssVarsByColorScheme` type from `CssVarsTheme`; it's only read through untyped code. - Sort the `scoped` propType alphabetically. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../customization/css-theme-variables/configuration.md | 8 ++++---- docs/data/material/customization/theming/theming.md | 2 +- .../mui-material/src/styles/createThemeFoundation.ts | 9 --------- .../mui-system/src/cssVars/createCssVarsProvider.tsx | 8 ++++---- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index bf425659ddbb84..69cace591304b6 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -279,8 +279,8 @@ In the example below, all the components inside the `div` will always be dark: ## Nested and multiple theme providers -The first `ThemeProvider` writes the CSS theme variables to `:root`, so they apply to the whole page. -Any other provider writes the same variables on an element that wraps its children, so they only apply there. +A `ThemeProvider` writes the CSS theme variables to `:root`, so they apply to the whole page. +A nested or `scoped` provider writes them on an element that wraps its children instead, so they only apply there. ### Nested providers @@ -329,7 +329,7 @@ To put the class on your own element instead of the `div`, for example inside a ### Sibling providers -A `ThemeProvider` that isn't nested in another one also writes to `:root`, so two of them overwrite each other. +Two `ThemeProvider`s that aren't nested in each other both write to `:root` and overwrite each other, which logs a warning in development. Add the `scoped` prop to the one that should only apply to its children, like an embedded widget: ```jsx @@ -342,7 +342,7 @@ Add the `scoped` prop to the one that should only apply to its children, like an ``` A scoped provider writes all of its variables on its element, and has its own light or dark mode. -When `scoped` is a string, it's added to the class name and to the storage keys of the mode, for example `mui-mode-widget`. +When `scoped` is a string, it's added to the class name and to the storage keys of the mode and color scheme, for example `mui-mode-widget`. :::info A CSS variable that you override on `:root` doesn't reach the elements under a scoped provider that sets it, because the closest declaration wins. diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index 3c657097ea1c78..d15068a075f890 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,7 +125,7 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} -With CSS theme variables, a nested theme sets its variables on an element that wraps its children. +With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on an element that wraps its children. See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. ## CSS theme variables diff --git a/packages/mui-material/src/styles/createThemeFoundation.ts b/packages/mui-material/src/styles/createThemeFoundation.ts index 84adde75ff1a18..5583e386d9f7cd 100644 --- a/packages/mui-material/src/styles/createThemeFoundation.ts +++ b/packages/mui-material/src/styles/createThemeFoundation.ts @@ -421,15 +421,6 @@ export interface CssVarsTheme extends ColorSystem { getColorSchemeSelector: (colorScheme: SupportedColorScheme) => string; generateThemeVars: () => ThemeVars; generateStyleSheets: () => Array>; - /** - * @internal Used by ThemeProvider to scope the CSS theme variables of nested themes. - */ - generateCssVarsByColorScheme?: - | (() => { - root: Record; - colorSchemes: Array<[string, Record]>; - }) - | undefined; generateSpacing: () => SystemTheme['spacing']; // Default theme tokens diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index cd0878d3a73ba6..ec0691a22630ba 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -688,15 +688,15 @@ export default function createCssVarsProvider< * You should use this option in conjunction with `InitColorSchemeScript` component. */ noSsr: PropTypes.bool, + /** + * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`. + */ + scoped: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** * The storage manager to be used for storing the mode and color scheme * @default using `window.localStorage` */ storageManager: PropTypes.func, - /** - * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`. - */ - scoped: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** * The window that attaches the 'storage' event listener. * @default window From 408e75d418432515e0fb12a9bde5b1662ed73798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 15:01:25 +0100 Subject: [PATCH 04/20] [system] Avoid unrelated refactors in the CSS variables provider Keep `generateStyleSheets` and the color scheme selector effect as they are on master. `generateCssVarsByColorScheme` and `getColorSchemeRule` are now only used for scoping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cssVars/createCssVarsProvider.tsx | 14 +++++++++-- .../mui-system/src/cssVars/prepareCssVars.ts | 25 +++++++++++++++++-- .../mui-system/src/cssVars/scopeCssVars.ts | 2 +- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index ec0691a22630ba..036a3e563e1a88 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -18,7 +18,6 @@ import ThemeScopeContext from './ThemeScopeContext'; import { createScopedStyleSheets, getColorSchemeProps, - getColorSchemeRule, type CssVarsByColorScheme, } from './scopeCssVars'; @@ -402,7 +401,18 @@ export default function createCssVarsProvider< colorSchemeSelector && colorSchemeSelector !== 'media' ) { - const rule = getColorSchemeRule(colorSchemeSelector)!; + const selector = colorSchemeSelector; + let rule = colorSchemeSelector; + if (selector === 'class') { + rule = `.%s`; + } + if (selector === 'data') { + rule = `[data-%s]`; + } + if (selector?.startsWith('data-') && !selector.includes('%s')) { + // 'data-mui-color-scheme' -> '[data-mui-color-scheme="%s"]' + rule = `[${selector}="%s"]`; + } if (rule.startsWith('.')) { colorSchemeNode.classList.remove( ...allColorSchemes.map((scheme) => rule.substring(1).replace('%s', scheme)), diff --git a/packages/mui-system/src/cssVars/prepareCssVars.ts b/packages/mui-system/src/cssVars/prepareCssVars.ts index d82345d10c7b5d..d71f0d223971da 100644 --- a/packages/mui-system/src/cssVars/prepareCssVars.ts +++ b/packages/mui-system/src/cssVars/prepareCssVars.ts @@ -133,6 +133,7 @@ function prepareCssVars { const stylesheets: Array> = []; + const colorScheme = theme.defaultColorScheme || 'light'; function insertStyleSheet(key: string | object, css: Record) { if (Object.keys(css).length) { stylesheets.push(typeof key === 'string' ? { [key]: { ...css } } : key); @@ -140,8 +141,28 @@ function prepareCssVars { + const { [colorScheme]: defaultSchemeVal, ...other } = colorSchemesMap; + + if (defaultSchemeVal) { + // default color scheme has to come before other color schemes + const { css } = defaultSchemeVal; + const cssColorSheme = colorSchemes[colorScheme]?.palette?.mode; + const finalCss = + !disableCssColorScheme && cssColorSheme + ? { colorScheme: cssColorSheme, ...css } + : { ...css }; + insertStyleSheet( + getSelector(colorScheme as keyof T['colorSchemes'], { ...finalCss }), + finalCss, + ); + } + + Object.entries(other).forEach(([key, { css }]) => { + const cssColorSheme = colorSchemes[key]?.palette?.mode; + const finalCss = + !disableCssColorScheme && cssColorSheme + ? { colorScheme: cssColorSheme, ...css } + : { ...css }; insertStyleSheet(getSelector(key as keyof T['colorSchemes'], { ...finalCss }), finalCss); }); diff --git a/packages/mui-system/src/cssVars/scopeCssVars.ts b/packages/mui-system/src/cssVars/scopeCssVars.ts index e7e4b0a3145fc9..9d15c0dac1b128 100644 --- a/packages/mui-system/src/cssVars/scopeCssVars.ts +++ b/packages/mui-system/src/cssVars/scopeCssVars.ts @@ -9,7 +9,7 @@ export interface CssVarsByColorScheme { * Turns `theme.colorSchemeSelector` into a rule with a `%s` placeholder for the color scheme, * or `'media'`. Returns `undefined` when there's no selector. */ -export function getColorSchemeRule(selector: string | undefined) { +function getColorSchemeRule(selector: string | undefined) { if (selector === 'class') { return '.%s'; } From 6929aa36e0c9aa90dbe12296fd569dd16b6a33a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 15:07:19 +0100 Subject: [PATCH 05/20] [system] Create the scoped stylesheets once per pair of themes Every nested provider generated and diffed the CSS variables of both themes on mount, which made 100 nested providers mount about 33% slower than on master. The result only depends on the themes, so cache it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cssVars/createCssVarsProvider.tsx | 68 +++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index 036a3e563e1a88..81e8b5de5d5341 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -238,6 +238,47 @@ export default function createCssVarsProvider< const defaultComponents = {}; const scopeStyle = { display: 'contents' }; + // The scoped stylesheets only depend on the themes, so they're created once per pair of themes + // instead of once per provider. + type ScopedStyleSheets = ReturnType; + const scopedStyleSheetsCache: WeakMap< + () => CssVarsByColorScheme, + WeakMap> + > = new WeakMap(); + const noUpper = {}; + function getScopedStyleSheets( + generate: () => CssVarsByColorScheme, + upperGenerate: (() => CssVarsByColorScheme) | undefined, + colorSchemeSelector: string | undefined, + prefix: string, + name: string | undefined, + ) { + let byUpper = scopedStyleSheetsCache.get(generate); + if (!byUpper) { + byUpper = new WeakMap(); + scopedStyleSheetsCache.set(generate, byUpper); + } + let byOptions = byUpper.get(upperGenerate ?? noUpper); + if (!byOptions) { + byOptions = new Map(); + byUpper.set(upperGenerate ?? noUpper, byOptions); + } + const key = `${colorSchemeSelector}|${prefix}|${name}`; + if (!byOptions.has(key)) { + byOptions.set( + key, + createScopedStyleSheets({ + css: generate(), + upper: upperGenerate?.(), + colorSchemeSelector, + prefix, + name, + }), + ); + } + return byOptions.get(key) as ScopedStyleSheets; + } + // Stylesheets of the mounted providers that write to `:root`, to warn when they overwrite each other. const rootStyleSheets: Map = new Map(); let warnedMultipleRoots = false; @@ -522,22 +563,23 @@ export default function createCssVarsProvider< if (!upperGenerateCssVarsByColorScheme) { return null; } - return createScopedStyleSheets({ - css: generateCssVarsByColorScheme(), - upper: upperGenerateCssVarsByColorScheme(), - // It follows the outer color scheme. - colorSchemeSelector: upperColorSchemeSelector, - prefix: cssVarPrefix, - name: scopeName, - }); + // It follows the outer color scheme. + return getScopedStyleSheets( + generateCssVarsByColorScheme, + upperGenerateCssVarsByColorScheme, + upperColorSchemeSelector, + cssVarPrefix, + scopeName, + ); } if (scoped) { - return createScopedStyleSheets({ - css: generateCssVarsByColorScheme(), + return getScopedStyleSheets( + generateCssVarsByColorScheme, + undefined, colorSchemeSelector, - prefix: cssVarPrefix, - name: scopeName, - }); + cssVarPrefix, + scopeName, + ); } return null; }, [ From 0b19a823043b12ee5129bb0cd5b443cdb4ac9a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 15:18:06 +0100 Subject: [PATCH 06/20] [test] Add ThemeProvider benchmark Measures mounting a root provider, and 100 nested providers whose CSS variables are the same as or differ from the outer theme's. --- .../src/themeProvider/themeProvider.bench.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/benchmark/src/themeProvider/themeProvider.bench.tsx diff --git a/test/benchmark/src/themeProvider/themeProvider.bench.tsx b/test/benchmark/src/themeProvider/themeProvider.bench.tsx new file mode 100644 index 00000000000000..511c1e2cbb2997 --- /dev/null +++ b/test/benchmark/src/themeProvider/themeProvider.bench.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { reactBenchmark } from '@mui/internal-benchmark/page'; +import Button from '@mui/material/Button'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +const appTheme = createTheme({ cssVariables: true, colorSchemes: { light: true, dark: true } }); +// Only changes component defaults, so its CSS variables are the same as the app theme's. +const sameVarsTheme = createTheme({ + cssVariables: true, + colorSchemes: { light: true, dark: true }, + components: { MuiButton: { defaultProps: { disableElevation: true } } }, +}); +const promoTheme = createTheme({ + cssVariables: true, + colorSchemes: { + light: { palette: { primary: { main: '#9c27b0' } } }, + dark: { palette: { primary: { main: '#ce93d8' } } }, + }, +}); + +function Buttons({ count }: { count: number }) { + return Array.from({ length: count }, (_, index) => ( + + )); +} + +function Sections({ theme, count }: { theme: typeof appTheme; count: number }) { + return Array.from({ length: count }, (_, index) => ( + + + + )); +} + +reactBenchmark('ThemeProvider root mount', () => ( + + + +)); + +reactBenchmark('ThemeProvider nested, same variables', () => ( + + + +)); + +reactBenchmark('ThemeProvider nested, different variables', () => ( + + + +)); From 1451e779d3341f3550c50340d018e8a28fbc1e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 15:25:28 +0100 Subject: [PATCH 07/20] [docs] Treat CSS theme variables as the default in the nested theme note --- docs/data/material/customization/theming/theming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index d15068a075f890..b8bfa4b8564b88 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,7 +125,7 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} -With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on an element that wraps its children. +A nested theme whose CSS theme variables differ from the outer theme sets them on an element that wraps its children. See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. ## CSS theme variables From 259accef9aefd6401bc86b63373ec618ae3951a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Mon, 26 Oct 2026 15:37:28 +0100 Subject: [PATCH 08/20] Revert "[docs] Treat CSS theme variables as the default in the nested theme note" This reverts commit 1451e779d3341f3550c50340d018e8a28fbc1e5f. --- docs/data/material/customization/theming/theming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index b8bfa4b8564b88..d15068a075f890 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,7 +125,7 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} -A nested theme whose CSS theme variables differ from the outer theme sets them on an element that wraps its children. +With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on an element that wraps its children. See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. ## CSS theme variables From 475d2db565621eada66af61a29cfc5fe5b4134d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 13:10:07 +0100 Subject: [PATCH 09/20] [system][docs] Address review of scoped ThemeProviders - An unnamed `scoped` provider no longer stores its mode, so it doesn't overwrite the mode of the page's provider. Named ones keep their suffixed storage keys. - Replace characters that aren't valid in a class in the `scoped` name. - Memoize the color scheme props instead of round-tripping them through JSON. - Keep master's `shouldGenerateStyleSheet` block and `upperTheme` declaration. - Document the wrapper's cost and the remount, that function themes aren't scoped, and add the v10 upgrade guide entry. - Clean up the color scheme class in `afterEach`. --- .../css-theme-variables/configuration.md | 24 +++++++---- .../material/customization/theming/theming.md | 1 + .../upgrade-to-v10/upgrade-to-v10.md | 29 ++++++++++++++ .../src/styles/ThemeProviderScoped.test.tsx | 21 +++++++++- .../src/cssVars/createCssVarsProvider.tsx | 40 +++++++++++++------ .../src/cssVars/scopeCssVars.test.ts | 10 +++++ .../mui-system/src/cssVars/scopeCssVars.ts | 4 +- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index 69cace591304b6..aa954066a74849 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -307,6 +307,10 @@ The class only sets the variables that differ, and it follows the outer light or A nested provider with the same variables, for example one that only changes `components` defaults, renders no element. +The wrapper is an extra element, so `> *` and `~` selectors, and the spacing of a `Stack`, don't reach through it. +Since it only renders when the variables differ, switching a nested provider between a theme with the same variables and one with different variables also remounts its children. +The `component` prop below avoids both: it always renders the element you choose. + Dialogs, menus, popovers, tooltips, and other components that render in a portal apply the class to their own root, so they use the nested theme too. For elements that other libraries portal, use the `useThemeScope()` hook to get the class: @@ -319,7 +323,8 @@ function Toast() { } ``` -To put the class on your own element instead of the `div`, for example inside a table or a list, where a `div` isn't valid HTML, use the `component` prop: +To put the class on your own element instead of the `div`, for example inside a table or a list, where a `div` isn't valid HTML, use the `component` prop. +It renders whether the variables differ or not: ```jsx @@ -333,16 +338,19 @@ Two `ThemeProvider`s that aren't nested in each other both write to `:root` and Add the `scoped` prop to the one that should only apply to its children, like an embedded widget: ```jsx - - - - - - + + + + + + + + ``` A scoped provider writes all of its variables on its element, and has its own light or dark mode. -When `scoped` is a string, it's added to the class name and to the storage keys of the mode and color scheme, for example `mui-mode-widget`. +To remember that mode across visits, give it a name: when `scoped` is a string, it's added to the class name and to the storage keys of the mode and color scheme, for example `mui-mode-widget`. +Without a name, the mode isn't stored, so it doesn't overwrite the mode of the page's provider. :::info A CSS variable that you override on `:root` doesn't reach the elements under a scoped provider that sets it, because the closest declaration wins. diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index d15068a075f890..d0cfc4f7dd9bb6 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -126,6 +126,7 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on an element that wraps its children. +This doesn't apply to a theme passed as a function, which still uses the outer theme's CSS variables. See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. ## CSS theme variables diff --git a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md index 16c5b27fa3c6d8..d3957e2c066ed2 100644 --- a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md +++ b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md @@ -1,3 +1,32 @@ # Upgrade to v10

This guide explains how to upgrade from Material UI v9 to v10.

+ +## Breaking changes + +### Theme + +#### Nested ThemeProviders with CSS theme variables + +A nested `ThemeProvider` whose theme uses CSS theme variables now applies its theme. +Previously, its variables were ignored when it had the same `cssVarPrefix` as the outer theme, so its subtree kept the outer theme's colors. + +When its variables differ from the outer theme, it wraps its children in a `div` with `display: contents` that sets them. +Selectors like `> *` and `~`, the spacing of a `Stack`, and DOM snapshots can be affected. +To render your own element instead, for example a `tbody` inside a table, use the `component` prop: + +```jsx + + {rows} + +``` + +Two `ThemeProvider`s that aren't nested in each other still overwrite each other's variables on `:root`, and now log a warning in development. +Add the `scoped` prop to the one that should only apply to its children: + +```diff +- ++ +``` + +See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx index 3ea187353b48f7..4819b8c8c9b4e2 100644 --- a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import * as React from 'react'; import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; @@ -16,6 +16,10 @@ const redTheme = createTheme({ cssVariables: true, palette: { primary: { main: ' describe('ThemeProvider with scoped CSS theme variables', () => { const { render } = createRenderer(); + afterEach(() => { + document.documentElement.classList.remove('light', 'dark'); + }); + describe('nested', () => { it('wraps the children in a scoped element when the variables differ', () => { render( @@ -135,7 +139,6 @@ describe('ThemeProvider with scoped CSS theme variables', () => { ); expect(document.documentElement.classList.contains('dark')).to.equal(true); expect(getVar(screen.getByTestId('inner'), '--mui-palette-primary-main')).to.equal('#00ff00'); - document.documentElement.classList.remove('dark'); }); }); @@ -164,6 +167,20 @@ describe('ThemeProvider with scoped CSS theme variables', () => { expect(getComputedStyle(screen.getByTestId('widget')).colorScheme).to.equal('dark'); }); + it('does not store the mode of an unnamed scoped provider', () => { + const theme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, + }); + window.localStorage.removeItem('mui-mode'); + render( + +
+ , + ); + expect(window.localStorage.getItem('mui-mode')).to.equal(null); + }); + it('uses the name in the class name', () => { render( diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index 81e8b5de5d5341..fef85cd1066eb0 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -302,7 +302,7 @@ export default function createCssVarsProvider< noSsr, } = props; const hasMounted = React.useRef(false); - const upperTheme = muiUseTheme() as any; + const upperTheme = muiUseTheme(); const ctx = React.useContext(ColorSchemeContext); const upperScope = React.useContext(ThemeScopeContext); const nested = !!ctx && !disableNestedContext; @@ -361,7 +361,9 @@ export default function createCssVarsProvider< modeStorageKey, colorSchemeStorageKey, defaultMode, - storageManager, + // An unnamed scoped provider would share the storage keys of the page's provider, so it doesn't store its mode. + storageManager: + isScopedRoot && !scopeName && props.storageManager === undefined ? null : storageManager, storageWindow, noSsr, }); @@ -546,14 +548,14 @@ export default function createCssVarsProvider< const canGenerateStyleSheet = !disableStyleSheetGeneration && restThemeProp.cssVariables !== false; - const samePrefixAsUpper = nested && upperTheme?.cssVarPrefix === cssVarPrefix; + const samePrefixAsUpper = nested && (upperTheme as any)?.cssVarPrefix === cssVarPrefix; // 6. Scope the CSS theme variables that can't go to `:root` to the provider's element. const generateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = restThemeProp.generateCssVarsByColorScheme; const upperGenerateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = - samePrefixAsUpper ? upperTheme.generateCssVarsByColorScheme : undefined; - const upperColorSchemeSelector = nested ? upperTheme?.colorSchemeSelector : undefined; + samePrefixAsUpper ? (upperTheme as any).generateCssVarsByColorScheme : undefined; + const upperColorSchemeSelector = nested ? (upperTheme as any)?.colorSchemeSelector : undefined; const scope = React.useMemo(() => { if (!canGenerateStyleSheet || !generateCssVarsByColorScheme) { return null; @@ -595,10 +597,14 @@ export default function createCssVarsProvider< ]); const scopeClassName = scope?.className; - const { className: colorSchemeClassName, ...colorSchemeAttributes } = isScopedRoot - ? getColorSchemeProps(colorSchemeSelector, colorScheme) - : ({} as Record); - const colorSchemeAttributesKey = JSON.stringify(colorSchemeAttributes); + const colorSchemeProps = React.useMemo( + () => + isScopedRoot + ? getColorSchemeProps(colorSchemeSelector, colorScheme) + : ({} as Record), + [isScopedRoot, colorSchemeSelector, colorScheme], + ); + const { className: colorSchemeClassName, ...colorSchemeAttributes } = colorSchemeProps; const scopeProps = React.useMemo(() => { if (!scopeClassName) { return undefined; @@ -606,12 +612,20 @@ export default function createCssVarsProvider< // Portaled elements aren't under the outer scopes' elements either, so they get them too. return { ...(nested ? upperScope : undefined), - ...JSON.parse(colorSchemeAttributesKey), + ...colorSchemeProps, className: clsx(nested && upperScope?.className, scopeClassName, colorSchemeClassName), }; - }, [scopeClassName, colorSchemeClassName, colorSchemeAttributesKey, nested, upperScope]); - - const shouldGenerateStyleSheet = canGenerateStyleSheet && !samePrefixAsUpper && !isScopedRoot; + }, [scopeClassName, colorSchemeClassName, colorSchemeProps, nested, upperScope]); + + let shouldGenerateStyleSheet = true; + if ( + disableStyleSheetGeneration || + restThemeProp.cssVariables === false || + (nested && (upperTheme as any)?.cssVarPrefix === cssVarPrefix) || + isScopedRoot + ) { + shouldGenerateStyleSheet = false; + } if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/mui-system/src/cssVars/scopeCssVars.test.ts b/packages/mui-system/src/cssVars/scopeCssVars.test.ts index bf893b6b6368fb..c7b9e87165c224 100644 --- a/packages/mui-system/src/cssVars/scopeCssVars.test.ts +++ b/packages/mui-system/src/cssVars/scopeCssVars.test.ts @@ -96,6 +96,16 @@ describe('createScopedStyleSheets', () => { }); }); + it('replaces the characters of the name that are not valid in a class', () => { + const result = createScopedStyleSheets({ + css: upper, + colorSchemeSelector: 'class', + prefix: 'mui', + name: 'my widget.v2', + })!; + expect(result.className).to.match(/^mui-theme-my-widget-v2-[a-z0-9]+$/); + }); + it('gives the same class name to the same declarations', () => { const options = { css: upper, colorSchemeSelector: 'class', prefix: 'mui' }; expect(createScopedStyleSheets(options)!.className).to.equal( diff --git a/packages/mui-system/src/cssVars/scopeCssVars.ts b/packages/mui-system/src/cssVars/scopeCssVars.ts index 9d15c0dac1b128..99438563dc3043 100644 --- a/packages/mui-system/src/cssVars/scopeCssVars.ts +++ b/packages/mui-system/src/cssVars/scopeCssVars.ts @@ -124,7 +124,9 @@ export function createScopedStyleSheets(options: { return null; } - const className = `${prefix ? `${prefix}-` : ''}theme-${name ? `${name}-` : ''}${hash( + // The name is only there to make the class readable, so characters that aren't valid in a class are replaced. + const safeName = name?.replace(/[^a-zA-Z0-9_-]/g, '-'); + const className = `${prefix ? `${prefix}-` : ''}theme-${safeName ? `${safeName}-` : ''}${hash( JSON.stringify([root, schemes, rule, !!upper]), )}`; const scope = `.${className}`; From 43cd1819578180094d2f223c0b75f973fa5ebd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 16:22:05 +0100 Subject: [PATCH 10/20] [system][docs] Set the scoped CSS theme variables on the provider's single child The provider no longer renders a `display: contents` wrapper. When it needs to scope, it adds the class (and a scoped root's color scheme attribute) to its single child, so it never adds DOM and switching themes doesn't remount the children. Other children log an error in development. The `component` and `className` props are removed. --- .../css-theme-variables/configuration.md | 39 +++--- .../material/customization/theming/theming.md | 2 +- .../upgrade-to-v10/upgrade-to-v10.md | 28 ++-- .../mui-material/src/styles/ThemeProvider.tsx | 11 +- .../src/styles/ThemeProviderScoped.test.tsx | 121 ++++++++++++++---- .../src/cssVars/createCssVarsProvider.tsx | 80 ++++++------ 6 files changed, 164 insertions(+), 117 deletions(-) diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index aa954066a74849..05cef7c348f413 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -280,22 +280,28 @@ In the example below, all the components inside the `div` will always be dark: ## Nested and multiple theme providers A `ThemeProvider` writes the CSS theme variables to `:root`, so they apply to the whole page. -A nested or `scoped` provider writes them on an element that wraps its children instead, so they only apply there. +A nested or `scoped` provider sets them on its child element instead, so they only apply there. ### Nested providers -A nested `ThemeProvider` whose variables differ from the outer theme wraps its children in a `div` with `display: contents` and a generated class. +When a nested `ThemeProvider`'s variables differ from the outer theme, it adds a generated class to its child. The class only sets the variables that differ, and it follows the outer light or dark mode: ```jsx - +
+ +
``` +```html +
…
+``` + ```css :root { --mui-palette-primary-main: #1976d2; @@ -305,11 +311,9 @@ The class only sets the variables that differ, and it follows the outer light or } ``` -A nested provider with the same variables, for example one that only changes `components` defaults, renders no element. - -The wrapper is an extra element, so `> *` and `~` selectors, and the spacing of a `Stack`, don't reach through it. -Since it only renders when the variables differ, switching a nested provider between a theme with the same variables and one with different variables also remounts its children. -The `component` prop below avoids both: it always renders the element you choose. +The provider doesn't render any element of its own, so the child has to be a single element that renders the `className` it receives. +With several children, a Fragment, or text, the variables aren't applied and an error is logged in development: wrap the children in one element, for example a `div`. +A nested provider whose theme has the same variables, for example one that only changes `components` defaults, accepts any children. Dialogs, menus, popovers, tooltips, and other components that render in a portal apply the class to their own root, so they use the nested theme too. For elements that other libraries portal, use the `useThemeScope()` hook to get the class: @@ -323,19 +327,10 @@ function Toast() { } ``` -To put the class on your own element instead of the `div`, for example inside a table or a list, where a `div` isn't valid HTML, use the `component` prop. -It renders whether the variables differ or not: - -```jsx - - {rows} - -``` - ### Sibling providers Two `ThemeProvider`s that aren't nested in each other both write to `:root` and overwrite each other, which logs a warning in development. -Add the `scoped` prop to the one that should only apply to its children, like an embedded widget: +Add the `scoped` prop to the one that should only apply to its child, like an embedded widget: ```jsx @@ -343,18 +338,20 @@ Add the `scoped` prop to the one that should only apply to its children, like an
- +
+ +
``` -A scoped provider writes all of its variables on its element, and has its own light or dark mode. +A scoped provider sets all of its variables on its child element, and has its own light or dark mode. To remember that mode across visits, give it a name: when `scoped` is a string, it's added to the class name and to the storage keys of the mode and color scheme, for example `mui-mode-widget`. Without a name, the mode isn't stored, so it doesn't overwrite the mode of the page's provider. :::info A CSS variable that you override on `:root` doesn't reach the elements under a scoped provider that sets it, because the closest declaration wins. -Override it on the scoped element instead, for example with the `className` prop. +Override it on the child element instead, for example with its own class. ::: ## Disabling CSS color scheme diff --git a/docs/data/material/customization/theming/theming.md b/docs/data/material/customization/theming/theming.md index d0cfc4f7dd9bb6..35507125f64c9e 100644 --- a/docs/data/material/customization/theming/theming.md +++ b/docs/data/material/customization/theming/theming.md @@ -125,7 +125,7 @@ You can extend the outer theme by providing a function: {{"component": "file://./demos/theme-nesting-extend/index.ts"}} -With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on an element that wraps its children. +With CSS theme variables, a nested theme whose variables differ from the outer theme sets them on its child element, so the child has to be a single element. This doesn't apply to a theme passed as a function, which still uses the outer theme's CSS variables. See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. diff --git a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md index d3957e2c066ed2..edd326a4209917 100644 --- a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md +++ b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md @@ -11,22 +11,22 @@ A nested `ThemeProvider` whose theme uses CSS theme variables now applies its theme. Previously, its variables were ignored when it had the same `cssVarPrefix` as the outer theme, so its subtree kept the outer theme's colors. -When its variables differ from the outer theme, it wraps its children in a `div` with `display: contents` that sets them. -Selectors like `> *` and `~`, the spacing of a `Stack`, and DOM snapshots can be affected. -To render your own element instead, for example a `tbody` inside a table, use the `component` prop: - -```jsx - - {rows} - -``` - -Two `ThemeProvider`s that aren't nested in each other still overwrite each other's variables on `:root`, and now log a warning in development. -Add the `scoped` prop to the one that should only apply to its children: +When its variables differ from the outer theme, it sets them with a class on its child, so give it a single child element: ```diff -- -+ + +- +-
++
++ ++
++
+ ``` +With several children, a Fragment, or a child that doesn't render its `className`, the variables aren't applied, and the first two log an error in development. + +Two `ThemeProvider`s that aren't nested in each other still overwrite each other's variables on `:root`, and now log a warning in development. +Add the `scoped` prop to the one that should only apply to its child, like an embedded widget. + See [Nested and multiple theme providers](/material-ui/customization/css-theme-variables/configuration/#nested-and-multiple-theme-providers) for details. diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index 7c297702ed97d1..7674d45b404d2a 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -33,7 +33,7 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true */ forceThemeRerender?: boolean | undefined; /** - * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`, + * If `true`, the CSS theme variables are set on its child element instead of `:root`, * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, * like an embedded widget. Nested providers are scoped automatically when their variables differ. * @@ -41,15 +41,6 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true * @default false */ scoped?: boolean | string | undefined; - /** - * The element that wraps the children of a scoped provider. - * By default, a `div` with `display: contents`. - */ - component?: React.ElementType | undefined; - /** - * The class name of the element that wraps the children of a scoped provider. - */ - className?: string | undefined; } : {}; diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx index 4819b8c8c9b4e2..dc17d98287a562 100644 --- a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import * as React from 'react'; -import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils'; +import { createRenderer, screen, isJsdom, fireEvent } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import Popper from '@mui/material/Popper'; @@ -21,23 +21,78 @@ describe('ThemeProvider with scoped CSS theme variables', () => { }); describe('nested', () => { - it('wraps the children in a scoped element when the variables differ', () => { + it('adds the scope class to the child when the variables differ', () => { render(
-
+
, ); - const wrapper = screen.getByTestId('inner').parentElement!; - expect(wrapper).not.to.equal(screen.getByTestId('outer')); - expect(wrapper.className).to.match(/^mui-theme-[a-z0-9]+$/); - expect(wrapper.style.display).to.equal('contents'); + const inner = screen.getByTestId('inner'); + expect(inner.parentElement).to.equal(screen.getByTestId('outer')); + expect(inner.className).to.match(/^inner mui-theme-[a-z0-9]+$/); + }); + + it('keeps the state of the child when the variables start to differ', () => { + function Counter() { + const [count, setCount] = React.useState(0); + return ( + + ); + } + const { setProps } = render( + + + + + , + ); + fireEvent.click(screen.getByTestId('counter')); + setProps({ + children: ( + + + + ), + }); + expect(screen.getByTestId('counter').textContent).to.equal('1'); + }); + + it('logs an error and applies nothing when the children are not a single element', () => { + expect(() => + render( + + +
+
+ + , + ), + ).toErrorDev("MUI: The CSS theme variables of this ThemeProvider can't be applied"); + expect(screen.getByTestId('first').className).to.equal(''); }); - it('renders no element when the theme is the same', () => { + it('logs an error when the child is a Fragment', () => { + expect(() => + render( + + + +
+
+ + + , + ), + ).toErrorDev("MUI: The CSS theme variables of this ThemeProvider can't be applied"); + }); + + it('leaves any children untouched when the theme is the same', () => { render(
@@ -47,10 +102,10 @@ describe('ThemeProvider with scoped CSS theme variables', () => {
, ); - expect(screen.getByTestId('inner').parentElement).to.equal(screen.getByTestId('outer')); + expect(screen.getByTestId('inner').className).to.equal(''); }); - it('renders no element when the theme only changes component defaults', () => { + it('leaves the child untouched when the theme only changes component defaults', () => { const theme = createTheme({ cssVariables: true, palette: { primary: { main: '#0000ff' } }, @@ -65,7 +120,7 @@ describe('ThemeProvider with scoped CSS theme variables', () => {
, ); - expect(screen.getByTestId('inner').parentElement).to.equal(screen.getByTestId('outer')); + expect(screen.getByTestId('inner').className).to.equal(''); }); it.skipIf(isJsdom())('applies the inner variables only to the children', () => { @@ -103,10 +158,12 @@ describe('ThemeProvider with scoped CSS theme variables', () => { render( - -
-
- +
+ +
+
+ +
, ); @@ -187,9 +244,21 @@ describe('ThemeProvider with scoped CSS theme variables', () => {
, ); - expect(screen.getByTestId('widget').parentElement!.className).to.match( - /^mui-theme-widget-[a-z0-9]+$/, + expect(screen.getByTestId('widget').className).to.match(/^mui-theme-widget-[a-z0-9]+$/); + }); + + it('adds the color scheme attribute to the child', () => { + const theme = createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { light: true, dark: true }, + }); + render( + +
+ , ); + expect(screen.getByTestId('widget')).to.have.attribute('data-dark', ''); + expect(document.documentElement).not.to.have.attribute('data-dark'); }); it('warns when two providers with different themes write to :root', () => { @@ -208,18 +277,14 @@ describe('ThemeProvider with scoped CSS theme variables', () => { }); }); - it('renders the `component` with the scope class', () => { + it('leaves the children of the top-level provider untouched', () => { render( - - -
- + +
+
, ); - const section = screen.getByTestId('inner').parentElement!; - expect(section.tagName).to.equal('SECTION'); - expect(section.className).to.match(/^mui-theme-[a-z0-9]+ promo$/); - expect(section.style.display).to.equal(''); + expect(screen.getByTestId('first').className).to.equal(''); }); it('exposes the scope class to elements that are portaled by other libraries', () => { @@ -230,7 +295,9 @@ describe('ThemeProvider with scoped CSS theme variables', () => { render( - +
+ +
, ); diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index fef85cd1066eb0..6201c43f7542ad 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -120,7 +120,7 @@ export interface CreateCssVarsProviderResult< */ disableStyleSheetGeneration?: boolean | undefined; /** - * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`, + * If `true`, the CSS theme variables are set on its child element instead of `:root`, * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, * like an embedded widget. Nested providers are scoped automatically when their variables differ. * @@ -128,15 +128,6 @@ export interface CreateCssVarsProviderResult< * @default false */ scoped?: boolean | string | undefined; - /** - * The element that wraps the children of a scoped provider. - * By default, a `div` with `display: contents`. - */ - component?: React.ElementType | undefined; - /** - * The class name of the element that wraps the children of a scoped provider. - */ - className?: string | undefined; } >, ) => React.JSX.Element; @@ -236,7 +227,6 @@ export default function createCssVarsProvider< const defaultColorSchemes = {}; const defaultComponents = {}; - const scopeStyle = { display: 'contents' }; // The scoped stylesheets only depend on the themes, so they're created once per pair of themes // instead of once per provider. @@ -288,8 +278,6 @@ export default function createCssVarsProvider< children, theme: themeProp, scoped = false, - component, - className, disableTransitionOnChange = designSystemTransitionOnChange, storageManager, storageWindow = typeof window === 'undefined' ? undefined : window, @@ -657,37 +645,49 @@ export default function createCssVarsProvider< }, [shouldGenerateStyleSheet, nested, memoTheme, cssVarPrefix]); } - let element = ( + // The scope class goes on the single child, so the provider never renders an element of its own. + const canCloneChild = React.isValidElement(children) && children.type !== React.Fragment; + let scopedChildren = children; + if (scopeClassName && canCloneChild) { + const child = children as React.ReactElement<{ className?: string | undefined }>; + scopedChildren = React.cloneElement(child, { + ...colorSchemeAttributes, + className: clsx(child.props.className, scopeClassName, colorSchemeClassName), + }); + } + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + const warnedChildren = React.useRef(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (scopeClassName && !canCloneChild && !warnedChildren.current) { + warnedChildren.current = true; + console.error( + [ + "MUI: The CSS theme variables of this ThemeProvider can't be applied because its children aren't a single element.", + 'Wrap its children in one element, for example a `div`, so it can set them on it.', + ].join('\n'), + ); + } + }, [scopeClassName, canCloneChild]); + } + + // The tree is the same whether the theme needs a scope or not, so switching themes keeps the children's state. + const element = ( - {children} + + {scopedChildren} + {shouldGenerateStyleSheet && ( )} + {scope && } ); - if (scope || component) { - const ScopeComponent = component || 'div'; - element = ( - - {scope && } - - {scopeProps ? ( - {element} - ) : ( - element - )} - - - ); - } - if (nested) { return element; } @@ -702,10 +702,6 @@ export default function createCssVarsProvider< * The component tree. */ children: PropTypes.node, - /** - * The class name of the element that wraps the children of a scoped provider. - */ - className: PropTypes.string, /** * The node used to attach the color-scheme attribute */ @@ -714,10 +710,6 @@ export default function createCssVarsProvider< * localStorage key used to store `colorScheme` */ colorSchemeStorageKey: PropTypes.string, - /** - * The element that wraps the children of a scoped provider. - */ - component: PropTypes.elementType, /** * The default mode when the storage is empty, * require the theme to have `colorSchemes` with light and dark. @@ -755,7 +747,7 @@ export default function createCssVarsProvider< */ noSsr: PropTypes.bool, /** - * If `true`, the CSS theme variables are set on an element that wraps the children instead of `:root`. + * If `true`, the CSS theme variables are set on its child element instead of `:root`. */ scoped: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), /** From f11a7da538a0f9924243c2b788f9aaf7985a9a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 16:23:00 +0100 Subject: [PATCH 11/20] [test] Remove the ThemeProvider benchmark It's kept out of this pull request, matching how the other benchmark files were added. The results are in the description. --- .../src/themeProvider/themeProvider.bench.tsx | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 test/benchmark/src/themeProvider/themeProvider.bench.tsx diff --git a/test/benchmark/src/themeProvider/themeProvider.bench.tsx b/test/benchmark/src/themeProvider/themeProvider.bench.tsx deleted file mode 100644 index 511c1e2cbb2997..00000000000000 --- a/test/benchmark/src/themeProvider/themeProvider.bench.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from 'react'; -import { reactBenchmark } from '@mui/internal-benchmark/page'; -import Button from '@mui/material/Button'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; - -const appTheme = createTheme({ cssVariables: true, colorSchemes: { light: true, dark: true } }); -// Only changes component defaults, so its CSS variables are the same as the app theme's. -const sameVarsTheme = createTheme({ - cssVariables: true, - colorSchemes: { light: true, dark: true }, - components: { MuiButton: { defaultProps: { disableElevation: true } } }, -}); -const promoTheme = createTheme({ - cssVariables: true, - colorSchemes: { - light: { palette: { primary: { main: '#9c27b0' } } }, - dark: { palette: { primary: { main: '#ce93d8' } } }, - }, -}); - -function Buttons({ count }: { count: number }) { - return Array.from({ length: count }, (_, index) => ( - - )); -} - -function Sections({ theme, count }: { theme: typeof appTheme; count: number }) { - return Array.from({ length: count }, (_, index) => ( - - - - )); -} - -reactBenchmark('ThemeProvider root mount', () => ( - - - -)); - -reactBenchmark('ThemeProvider nested, same variables', () => ( - - - -)); - -reactBenchmark('ThemeProvider nested, different variables', () => ( - - - -)); From ea5ad042550e45ed6d778dff0f05a17a25d9d769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 17:02:03 +0100 Subject: [PATCH 12/20] [test] Use a fake storage window in the scoped provider storage test CI mocks window.localStorage without removeItem, so the test now checks which keys the providers read from a storage window it controls. --- .../src/styles/ThemeProviderScoped.test.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx index dc17d98287a562..e3fdb606dbcac8 100644 --- a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -224,18 +224,37 @@ describe('ThemeProvider with scoped CSS theme variables', () => { expect(getComputedStyle(screen.getByTestId('widget')).colorScheme).to.equal('dark'); }); - it('does not store the mode of an unnamed scoped provider', () => { + it('only uses the storage for a named scoped provider', () => { const theme = createTheme({ cssVariables: { colorSchemeSelector: 'class' }, colorSchemes: { light: true, dark: true }, }); - window.localStorage.removeItem('mui-mode'); + const readKeys: string[] = []; + const storageWindow = { + localStorage: { + getItem: (key: string) => { + readKeys.push(key); + return null; + }, + setItem: () => {}, + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as Window; render( - -
- , + + +
+ + +
+ + , + ); + expect(readKeys.length).to.be.greaterThan(0); + expect(readKeys.every((key) => key.endsWith('-widget') || key.includes('-widget-'))).to.equal( + true, ); - expect(window.localStorage.getItem('mui-mode')).to.equal(null); }); it('uses the name in the class name', () => { From 618ec3fccaaddb40d8969cae2fceef2a24f74003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 22:36:57 +0100 Subject: [PATCH 13/20] [system][docs] Pass the scope through directly nested ThemeProviders - A provider whose child is another ThemeProvider lost its scope, because the inner provider ignored the class it received. Providers, including ones with a function or non-CSS variables theme, now pass that class and the color scheme attributes on to their own child. - Don't log the single-child error when there are no children. - Docs: show a custom component that forwards `className`, mention `data-*` for a scoped provider with a data attribute selector, and don't use CssBaseline in the migration example. - Update comments and the `scoped` JSDoc for the single-child requirement. --- .../css-theme-variables/configuration.md | 9 ++++ .../upgrade-to-v10/upgrade-to-v10.md | 4 +- .../mui-material/src/styles/ThemeProvider.tsx | 23 +++++++++- .../src/styles/ThemeProviderScoped.test.tsx | 42 +++++++++++++++++++ .../src/cssVars/createCssVarsProvider.tsx | 31 +++++++++++--- 5 files changed, 100 insertions(+), 9 deletions(-) diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md index 05cef7c348f413..2c17437ad44624 100644 --- a/docs/data/material/customization/css-theme-variables/configuration.md +++ b/docs/data/material/customization/css-theme-variables/configuration.md @@ -313,6 +313,14 @@ The class only sets the variables that differ, and it follows the outer light or The provider doesn't render any element of its own, so the child has to be a single element that renders the `className` it receives. With several children, a Fragment, or text, the variables aren't applied and an error is logged in development: wrap the children in one element, for example a `div`. +If the child is your own component, pass `className` on to its root element: + +```jsx +function Section({ className, children }) { + return
{children}
; +} +``` + A nested provider whose theme has the same variables, for example one that only changes `components` defaults, accepts any children. Dialogs, menus, popovers, tooltips, and other components that render in a portal apply the class to their own root, so they use the nested theme too. @@ -346,6 +354,7 @@ Add the `scoped` prop to the one that should only apply to its child, like an em ``` A scoped provider sets all of its variables on its child element, and has its own light or dark mode. +If the theme's `colorSchemeSelector` uses a data attribute, the child also needs to render the `data-*` props it receives. To remember that mode across visits, give it a name: when `scoped` is a string, it's added to the class name and to the storage keys of the mode and color scheme, for example `mui-mode-widget`. Without a name, the mode isn't stored, so it doesn't overwrite the mode of the page's provider. diff --git a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md index edd326a4209917..cfb56dff56db4c 100644 --- a/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md +++ b/docs/data/material/migration/upgrade-to-v10/upgrade-to-v10.md @@ -15,10 +15,10 @@ When its variables differ from the outer theme, it sets them with a class on its ```diff -- +-
-
+
-+ ++
+
+
diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index 7674d45b404d2a..447267667a730d 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import clsx from 'clsx'; import { DefaultTheme } from '@mui/system'; import { StorageManager } from '@mui/system/cssVars'; import ThemeProviderNoVars from './ThemeProviderNoVars'; @@ -36,6 +37,7 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true * If `true`, the CSS theme variables are set on its child element instead of `:root`, * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, * like an embedded widget. Nested providers are scoped automatically when their variables differ. + * Either way, the provider needs a single child element that renders the `className` it receives. * * A string is used as a readable part of the class name, and to suffix the storage keys. * @default false @@ -112,7 +114,26 @@ export default function ThemeProvider({ }, [theme]); if (noVarsTheme) { - return ; + // It doesn't scope its own theme, but passes on the scope that an outer provider gave it to its child. + const forwardedClassName = (props as { className?: string | undefined }).className; + let { children } = props; + if (forwardedClassName && React.isValidElement(children) && children.type !== React.Fragment) { + const child = children as React.ReactElement<{ className?: string | undefined }>; + const forwarded: Record = { + className: clsx(child.props.className, forwardedClassName), + }; + Object.keys(props).forEach((key) => { + if (key.startsWith('data-')) { + forwarded[key] = (props as Record)[key]; + } + }); + children = React.cloneElement(child, forwarded); + } + return ( + + {children} + + ); } return ; } diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx index e3fdb606dbcac8..d7529d53ddd352 100644 --- a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -77,6 +77,48 @@ describe('ThemeProvider with scoped CSS theme variables', () => { expect(screen.getByTestId('first').className).to.equal(''); }); + it('does not log an error when there are no children', () => { + render( + + {false} + {null} + , + ); + }); + + it.skipIf(isJsdom())('passes the scope of a provider whose child is another provider', () => { + const greenSecondaryTheme = createTheme({ + cssVariables: true, + palette: { primary: { main: '#ff0000' }, secondary: { main: '#00ff00' } }, + }); + render( + + + +
+ + + , + ); + expect(getVar(screen.getByTestId('inner'), '--mui-palette-primary-main')).to.equal('#ff0000'); + expect(getVar(screen.getByTestId('inner'), '--mui-palette-secondary-main')).to.equal( + '#00ff00', + ); + }); + + it('passes the scope through a provider with a function theme', () => { + render( + + + ({ ...outer })}> +
+ + + , + ); + expect(screen.getByTestId('inner').className).to.match(/^mui-theme-[a-z0-9]+$/); + }); + it('logs an error when the child is a Fragment', () => { expect(() => render( diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index 6201c43f7542ad..9e89781ee0fc73 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -123,6 +123,7 @@ export interface CreateCssVarsProviderResult< * If `true`, the CSS theme variables are set on its child element instead of `:root`, * so they don't affect the rest of the page. Use it for a provider that isn't nested in another one, * like an embedded widget. Nested providers are scoped automatically when their variables differ. + * Either way, the provider needs a single child element that renders the `className` it receives. * * A string is used as a readable part of the class name, and to suffix the storage keys. * @default false @@ -421,7 +422,7 @@ export default function createCssVarsProvider< // 5. Declaring effects // 5.1 Updates the selector value to use the current color scheme which tells CSS to use the proper stylesheet. - // A scoped root renders it on its own element instead. + // A scoped root sets it on its child instead. const colorSchemeSelector = restThemeProp.colorSchemeSelector; const ownsColorSchemeNode = !isScopedRoot || props.colorSchemeNode !== undefined; useEnhancedEffect(() => { @@ -538,7 +539,7 @@ export default function createCssVarsProvider< !disableStyleSheetGeneration && restThemeProp.cssVariables !== false; const samePrefixAsUpper = nested && (upperTheme as any)?.cssVarPrefix === cssVarPrefix; - // 6. Scope the CSS theme variables that can't go to `:root` to the provider's element. + // 6. Scope the CSS theme variables that can't go to `:root` to the provider's child. const generateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = restThemeProp.generateCssVarsByColorScheme; const upperGenerateCssVarsByColorScheme: (() => CssVarsByColorScheme) | undefined = @@ -646,13 +647,31 @@ export default function createCssVarsProvider< } // The scope class goes on the single child, so the provider never renders an element of its own. + // When the child is itself a ThemeProvider, it receives the outer scope and passes it on to its own child. + const forwardedClassName: string | undefined = props.className; + const needsChildScope = !!(scopeClassName || forwardedClassName); + const hasChildren = children != null && typeof children !== 'boolean'; const canCloneChild = React.isValidElement(children) && children.type !== React.Fragment; let scopedChildren = children; - if (scopeClassName && canCloneChild) { + if (needsChildScope && canCloneChild) { const child = children as React.ReactElement<{ className?: string | undefined }>; + const forwardedAttributes: Record = {}; + if (forwardedClassName) { + Object.keys(props).forEach((key) => { + if (key.startsWith('data-')) { + forwardedAttributes[key] = props[key]; + } + }); + } scopedChildren = React.cloneElement(child, { + ...forwardedAttributes, ...colorSchemeAttributes, - className: clsx(child.props.className, scopeClassName, colorSchemeClassName), + className: clsx( + child.props.className, + forwardedClassName, + scopeClassName, + colorSchemeClassName, + ), }); } @@ -661,7 +680,7 @@ export default function createCssVarsProvider< const warnedChildren = React.useRef(false); // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { - if (scopeClassName && !canCloneChild && !warnedChildren.current) { + if (needsChildScope && hasChildren && !canCloneChild && !warnedChildren.current) { warnedChildren.current = true; console.error( [ @@ -670,7 +689,7 @@ export default function createCssVarsProvider< ].join('\n'), ); } - }, [scopeClassName, canCloneChild]); + }, [needsChildScope, hasChildren, canCloneChild]); } // The tree is the same whether the theme needs a scope or not, so switching themes keeps the children's state. From eae0c4ca4ff584ca6258117c41f0096cd8db2d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 23:08:45 +0100 Subject: [PATCH 14/20] [material-ui] Don't pass the forwarded scope props to the system ThemeProvider The system ThemeProvider only accepts its own props, so on React 17 and 18 the forwarded `className` and `data-*` logged a prop types error in development when the middle provider had a function or non-CSS variables theme. They're now only cloned onto the child. Adds a test for the color scheme attribute passing through a function theme. --- .../mui-material/src/styles/ThemeProvider.tsx | 23 +++++++++++-------- .../src/styles/ThemeProviderScoped.test.tsx | 16 +++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index 447267667a730d..d5414a8281e391 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -115,22 +115,27 @@ export default function ThemeProvider({ if (noVarsTheme) { // It doesn't scope its own theme, but passes on the scope that an outer provider gave it to its child. - const forwardedClassName = (props as { className?: string | undefined }).className; + // The system ThemeProvider only accepts its own props, so the forwarded ones don't reach it. + const { className: forwardedClassName, ...noVarsProps } = props as typeof props & { + className?: string | undefined; + }; + const forwarded: Record = {}; + Object.keys(noVarsProps).forEach((key) => { + if (key.startsWith('data-')) { + forwarded[key] = (noVarsProps as Record)[key]; + delete (noVarsProps as Record)[key]; + } + }); let { children } = props; if (forwardedClassName && React.isValidElement(children) && children.type !== React.Fragment) { const child = children as React.ReactElement<{ className?: string | undefined }>; - const forwarded: Record = { + children = React.cloneElement(child, { + ...forwarded, className: clsx(child.props.className, forwardedClassName), - }; - Object.keys(props).forEach((key) => { - if (key.startsWith('data-')) { - forwarded[key] = (props as Record)[key]; - } }); - children = React.cloneElement(child, forwarded); } return ( - + {children} ); diff --git a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx index d7529d53ddd352..008818a2692c76 100644 --- a/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx +++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx @@ -119,6 +119,22 @@ describe('ThemeProvider with scoped CSS theme variables', () => { expect(screen.getByTestId('inner').className).to.match(/^mui-theme-[a-z0-9]+$/); }); + it('passes the color scheme attribute of a scoped provider through a function theme', () => { + const theme = createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { light: true, dark: true }, + }); + render( + + ({ ...outer })}> +
+ + , + ); + expect(screen.getByTestId('widget')).to.have.attribute('data-dark', ''); + expect(screen.getByTestId('widget').className).to.match(/^mui-theme-[a-z0-9]+$/); + }); + it('logs an error when the child is a Fragment', () => { expect(() => render( From 6d8f9fbdea80714a3a71d8a6dc80f15f4f132475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 23:17:49 +0100 Subject: [PATCH 15/20] [system][material-ui] Share the scope forwarding between the ThemeProviders The CSS variables provider and Material's ThemeProvider for themes without CSS variables both passed the outer scope on to their child with their own copy of the code. They now use one helper, exported from `@mui/system/cssVars` as `unstable_applyScopeToChild`. --- .../mui-material/src/styles/ThemeProvider.tsx | 20 +++------- .../src/cssVars/createCssVarsProvider.tsx | 38 +++++++------------ packages/mui-system/src/cssVars/index.ts | 1 + .../mui-system/src/cssVars/scopeCssVars.ts | 36 ++++++++++++++++++ 4 files changed, 56 insertions(+), 39 deletions(-) diff --git a/packages/mui-material/src/styles/ThemeProvider.tsx b/packages/mui-material/src/styles/ThemeProvider.tsx index d5414a8281e391..7e83f965708e51 100644 --- a/packages/mui-material/src/styles/ThemeProvider.tsx +++ b/packages/mui-material/src/styles/ThemeProvider.tsx @@ -1,8 +1,10 @@ 'use client'; import * as React from 'react'; -import clsx from 'clsx'; import { DefaultTheme } from '@mui/system'; -import { StorageManager } from '@mui/system/cssVars'; +import { + StorageManager, + unstable_applyScopeToChild as applyScopeToChild, +} from '@mui/system/cssVars'; import ThemeProviderNoVars from './ThemeProviderNoVars'; import { CssThemeVariables } from './createThemeNoVars'; import { CssVarsProvider } from './ThemeProviderWithVars'; @@ -116,27 +118,17 @@ export default function ThemeProvider({ if (noVarsTheme) { // It doesn't scope its own theme, but passes on the scope that an outer provider gave it to its child. // The system ThemeProvider only accepts its own props, so the forwarded ones don't reach it. - const { className: forwardedClassName, ...noVarsProps } = props as typeof props & { + const { className, ...noVarsProps } = props as typeof props & { className?: string | undefined; }; - const forwarded: Record = {}; Object.keys(noVarsProps).forEach((key) => { if (key.startsWith('data-')) { - forwarded[key] = (noVarsProps as Record)[key]; delete (noVarsProps as Record)[key]; } }); - let { children } = props; - if (forwardedClassName && React.isValidElement(children) && children.type !== React.Fragment) { - const child = children as React.ReactElement<{ className?: string | undefined }>; - children = React.cloneElement(child, { - ...forwarded, - className: clsx(child.props.className, forwardedClassName), - }); - } return ( - {children} + {applyScopeToChild(props.children, props)} ); } diff --git a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx index 9e89781ee0fc73..7f1436f75417c0 100644 --- a/packages/mui-system/src/cssVars/createCssVarsProvider.tsx +++ b/packages/mui-system/src/cssVars/createCssVarsProvider.tsx @@ -16,6 +16,7 @@ import useCurrentColorScheme, { type Result } from './useCurrentColorScheme'; import type { StorageManager } from './localStorageManager'; import ThemeScopeContext from './ThemeScopeContext'; import { + applyScopeToChild, createScopedStyleSheets, getColorSchemeProps, type CssVarsByColorScheme, @@ -648,34 +649,21 @@ export default function createCssVarsProvider< // The scope class goes on the single child, so the provider never renders an element of its own. // When the child is itself a ThemeProvider, it receives the outer scope and passes it on to its own child. - const forwardedClassName: string | undefined = props.className; - const needsChildScope = !!(scopeClassName || forwardedClassName); - const hasChildren = children != null && typeof children !== 'boolean'; - const canCloneChild = React.isValidElement(children) && children.type !== React.Fragment; - let scopedChildren = children; - if (needsChildScope && canCloneChild) { - const child = children as React.ReactElement<{ className?: string | undefined }>; - const forwardedAttributes: Record = {}; - if (forwardedClassName) { - Object.keys(props).forEach((key) => { - if (key.startsWith('data-')) { - forwardedAttributes[key] = props[key]; + const scopedChildren = applyScopeToChild( + children, + props, + scopeClassName + ? { + className: clsx(scopeClassName, colorSchemeClassName), + attributes: colorSchemeAttributes, } - }); - } - scopedChildren = React.cloneElement(child, { - ...forwardedAttributes, - ...colorSchemeAttributes, - className: clsx( - child.props.className, - forwardedClassName, - scopeClassName, - colorSchemeClassName, - ), - }); - } + : undefined, + ); if (process.env.NODE_ENV !== 'production') { + const needsChildScope = !!(scopeClassName || props.className); + const hasChildren = children != null && typeof children !== 'boolean'; + const canCloneChild = React.isValidElement(children) && children.type !== React.Fragment; // eslint-disable-next-line react-hooks/rules-of-hooks const warnedChildren = React.useRef(false); // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/mui-system/src/cssVars/index.ts b/packages/mui-system/src/cssVars/index.ts index 9fc9a2259ec334..de5eb7d5c8cc45 100644 --- a/packages/mui-system/src/cssVars/index.ts +++ b/packages/mui-system/src/cssVars/index.ts @@ -13,3 +13,4 @@ export { createGetColorSchemeSelector } from './getColorSchemeSelector'; export type { StorageManager } from './localStorageManager'; export { useThemeScope } from './ThemeScopeContext'; export type { ThemeScopeProps } from './ThemeScopeContext'; +export { applyScopeToChild as unstable_applyScopeToChild } from './scopeCssVars'; diff --git a/packages/mui-system/src/cssVars/scopeCssVars.ts b/packages/mui-system/src/cssVars/scopeCssVars.ts index 99438563dc3043..ecc4d4c172cc6d 100644 --- a/packages/mui-system/src/cssVars/scopeCssVars.ts +++ b/packages/mui-system/src/cssVars/scopeCssVars.ts @@ -1,3 +1,6 @@ +import * as React from 'react'; +import clsx from 'clsx'; + type CssDeclarations = Record; export interface CssVarsByColorScheme { @@ -163,3 +166,36 @@ export function createScopedStyleSheets(options: { return { className, styles }; } + +/** + * Adds the scope that an outer ThemeProvider passed in `props` (`className` and `data-*`), + * and the provider's own scope, to its single child element. + * Returns the children unchanged when there's nothing to add or they aren't a single element. + */ +export function applyScopeToChild( + children: React.ReactNode, + props: Record, + own?: { className?: string | undefined; attributes?: Record | undefined }, +): React.ReactNode { + const forwardedClassName: string | undefined = props.className; + if (!forwardedClassName && !own?.className) { + return children; + } + if (!React.isValidElement(children) || children.type === React.Fragment) { + return children; + } + const child = children as React.ReactElement<{ className?: string | undefined }>; + const forwardedAttributes: Record = {}; + if (forwardedClassName) { + Object.keys(props).forEach((key) => { + if (key.startsWith('data-')) { + forwardedAttributes[key] = props[key]; + } + }); + } + return React.cloneElement(child, { + ...forwardedAttributes, + ...own?.attributes, + className: clsx(child.props.className, forwardedClassName, own?.className), + }); +} From 5c5dde4083177fc5d4515869db196fe451c81dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aar=C3=B3n=20Garc=C3=ADa=20Herv=C3=A1s?= Date: Tue, 27 Oct 2026 23:27:47 +0100 Subject: [PATCH 16/20] [docs] Add a temporary ThemeProvider scoping playground Each scenario renders in its own iframe next to an inspector of the scope classes, computed variables and generated rules. To be removed before merging. --- .../experiments/theme-provider-scoping.tsx | 799 ++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 docs/pages/experiments/theme-provider-scoping.tsx diff --git a/docs/pages/experiments/theme-provider-scoping.tsx b/docs/pages/experiments/theme-provider-scoping.tsx new file mode 100644 index 00000000000000..951a6538d3861a --- /dev/null +++ b/docs/pages/experiments/theme-provider-scoping.tsx @@ -0,0 +1,799 @@ +'use client'; +// Temporary playground for https://github.com/mui/material-ui/pull/49581. Remove before merging. +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import createCache, { EmotionCache } from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Tooltip from '@mui/material/Tooltip'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { + ThemeProvider as MaterialThemeProvider, + createTheme, + useColorScheme, + useThemeScope, +} from '@mui/material/styles'; + +// The CSS variables props (`scoped`, `colorSchemeNode`, …) are only typed with the CSS variables augmentation. +const ThemeProvider = MaterialThemeProvider as React.ComponentType; + +const appTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, +}); +const sameVarsTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { light: true, dark: true }, + components: { MuiButton: { defaultProps: { disableElevation: true } } }, +}); +const promoTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { + light: { palette: { primary: { main: '#9c27b0' } } }, + dark: { palette: { primary: { main: '#ce93d8' } } }, + }, +}); +const accentTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { + light: { palette: { primary: { main: '#9c27b0' }, secondary: { main: '#2e7d32' } } }, + dark: { palette: { primary: { main: '#ce93d8' }, secondary: { main: '#81c784' } } }, + }, +}); +const widgetTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + colorSchemes: { + light: { palette: { primary: { main: '#e65100' } } }, + dark: { palette: { primary: { main: '#ffb74d' } } }, + }, +}); +const widgetDataTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'data' }, + colorSchemes: { + light: { palette: { primary: { main: '#e65100' } } }, + dark: { palette: { primary: { main: '#ffb74d' } } }, + }, +}); +const forcedDarkTheme = createTheme({ + cssVariables: { colorSchemeSelector: 'class' }, + palette: { mode: 'dark', primary: { main: '#f48fb1' } }, +}); + +// --------------------------------------------------------------------------- +// Each scenario renders in its own iframe, because root providers write to `:root`. + +interface FrameValue { + document: Document; + window: Window; +} + +const FrameContext = React.createContext(null); + +function useFrame() { + return React.useContext(FrameContext)!; +} + +let frameCount = 0; + +function Frame(props: { + children: React.ReactNode; + height: number; + onReady: (frame: FrameValue) => void; +}) { + const { children, height, onReady } = props; + const ref = React.useRef(null); + const [frame, setFrame] = React.useState<(FrameValue & { cache: EmotionCache }) | null>(null); + + React.useEffect(() => { + const iframe = ref.current!; + const init = () => { + frameCount += 1; + const value = { + document: iframe.contentDocument!, + window: iframe.contentWindow!, + cache: createCache({ + key: `frame${String.fromCharCode(96 + (frameCount % 26 || 26))}`, + container: iframe.contentDocument!.head, + }), + }; + setFrame(value); + onReady(value); + }; + if (iframe.contentDocument?.readyState === 'complete' && iframe.contentDocument.body) { + init(); + return undefined; + } + iframe.addEventListener('load', init); + return () => iframe.removeEventListener('load', init); + }, [onReady]); + + return ( + +