diff --git a/docs/data/material/customization/css-theme-variables/configuration.md b/docs/data/material/customization/css-theme-variables/configuration.md
index af198b2064d4ae..fb9ff1a2f5d49a 100644
--- a/docs/data/material/customization/css-theme-variables/configuration.md
+++ b/docs/data/material/customization/css-theme-variables/configuration.md
@@ -277,6 +277,92 @@ 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 sets them on its child element instead, so they only apply there.
+
+### Nested providers
+
+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;
+}
+.mui-theme-1x2y3z {
+ --mui-palette-primary-main: #9c27b0;
+}
+```
+
+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.
+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);
+}
+```
+
+### 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 child, like an embedded widget:
+
+```jsx
+
+
+
+
+
+
+
+
+
+
+```
+
+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.
+Its mode isn't stored, so it doesn't overwrite the mode of the page's provider.
+To remember it across visits, pass your own `modeStorageKey` and `colorSchemeStorageKey`.
+
+:::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 child element instead, for example with its own class.
+:::
+
## 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..35507125f64c9e 100644
--- a/docs/data/material/customization/theming/theming.md
+++ b/docs/data/material/customization/theming/theming.md
@@ -125,6 +125,10 @@ 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 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.
+
## CSS theme variables
To generate CSS variables from the theme, set `cssVariables` to `true` in the theme configuration and pass it to the `ThemeProvider`:
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..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
@@ -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 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/docs/pages/experiments/theme-provider-scoping.tsx b/docs/pages/experiments/theme-provider-scoping.tsx
new file mode 100644
index 00000000000000..49869d385f223f
--- /dev/null
+++ b/docs/pages/experiments/theme-provider-scoping.tsx
@@ -0,0 +1,695 @@
+'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 Dialog from '@mui/material/Dialog';
+import DialogContent from '@mui/material/DialogContent';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import Stack from '@mui/material/Stack';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+import {
+ ThemeProvider as MaterialThemeProvider,
+ createTheme,
+ useColorScheme,
+ useThemeScope,
+} from '@mui/material/styles';
+import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout';
+
+// The CSS variables props (`scoped`, `colorSchemeNode`, …) are only typed with the CSS variables augmentation.
+const ThemeProvider = MaterialThemeProvider as React.ComponentType;
+
+function createColorTheme(light: object, dark: object) {
+ return createTheme({
+ cssVariables: { colorSchemeSelector: 'class' },
+ colorSchemes: { light: { palette: light }, dark: { palette: dark } },
+ });
+}
+
+const appTheme = createColorTheme({}, {});
+const sameVarsTheme = createTheme({
+ cssVariables: { colorSchemeSelector: 'class' },
+ colorSchemes: { light: true, dark: true },
+ components: { MuiButton: { defaultProps: { disableElevation: true } } },
+});
+const promoTheme = createColorTheme(
+ { primary: { main: '#9c27b0' } },
+ { primary: { main: '#ce93d8' } },
+);
+const accentTheme = createColorTheme(
+ { primary: { main: '#9c27b0' }, secondary: { main: '#2e7d32' } },
+ { primary: { main: '#ce93d8' }, secondary: { main: '#81c784' } },
+);
+const widgetTheme = createColorTheme(
+ { primary: { main: '#e65100' } },
+ { 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)!;
+}
+
+function Frame(props: { children: React.ReactNode; onReady: (frame: FrameValue) => void }) {
+ const { children, onReady } = props;
+ const ref = React.useRef(null);
+ const [frame, setFrame] = React.useState<(FrameValue & { cache: EmotionCache }) | null>(null);
+ const [height, setHeight] = React.useState(80);
+ // Emotion keys only allow lowercase letters and hyphens, so the id's other characters become letters.
+ const id = React.useId();
+ const cacheKey = `frame-${id.toLowerCase().replace(/[^a-z]/g, (char) => String.fromCharCode(97 + (char.charCodeAt(0) % 26)))}`;
+
+ React.useEffect(() => {
+ const iframe = ref.current!;
+ let observer: ResizeObserver | undefined;
+ const init = () => {
+ const doc = iframe.contentDocument!;
+ const value = {
+ document: doc,
+ window: iframe.contentWindow!,
+ cache: createCache({
+ key: cacheKey,
+ container: doc.head,
+ }),
+ };
+ // The iframe grows with its content, plus some room for menus and tooltips.
+ observer = new ResizeObserver(() => setHeight(doc.body.scrollHeight + 24));
+ observer.observe(doc.body);
+ setFrame(value);
+ onReady(value);
+ };
+ if (iframe.contentDocument?.readyState === 'complete' && iframe.contentDocument.body) {
+ init();
+ } else {
+ iframe.addEventListener('load', init);
+ }
+ return () => {
+ iframe.removeEventListener('load', init);
+ observer?.disconnect();
+ };
+ }, [onReady, cacheKey]);
+
+ return (
+
+
+ {frame &&
+ ReactDOM.createPortal(
+
+ {children}
+ ,
+ frame.document.body,
+ )}
+
+ );
+}
+
+// A root provider of the scenario: it writes to the iframe's `:root`, isolated from the docs' own provider.
+function Root(props: Record) {
+ const { document, window } = useFrame();
+ return (
+
+ );
+}
+
+// A scoped sibling: isolated from the docs' provider too, it manages its own mode on its child.
+function ScopedRoot(props: Record) {
+ const { document } = useFrame();
+ return ;
+}
+
+function Swatch({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+function ModeToggle({ label = 'mode' }: { label?: string }) {
+ const { mode, setMode } = useColorScheme();
+ return (
+
+ );
+}
+
+function Portals({ label }: { label: string }) {
+ const { document } = useFrame();
+ const themeScope = useThemeScope();
+ const [anchor, setAnchor] = React.useState(null);
+ const [dialogOpen, setDialogOpen] = React.useState(false);
+ return (
+
+
+ ),
+ },
+ {
+ title: '2. Two root providers',
+ description:
+ 'Both write to :root, and the last one wins for both. Logs a warning in development.',
+ code: `
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '3. Root + scoped siblings',
+ description:
+ 'A scoped provider sets all its variables on its child, and has its own mode. With a data attribute selector, the color scheme attribute goes on the child too.',
+ code: `
+
+
+
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '4. Nested, different variables',
+ description:
+ 'The child gets a class that only sets the variables that differ. It follows the outer mode.',
+ code: `
+
+
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '5. Nested, children that are not a single element',
+ description:
+ 'Several children, a Fragment, text and an array: the rule is generated, but no element gets the class, and an error is logged in development.',
+ code: `
+<>…>
+text
+{[]}`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+ text
+ {[]}
+
+
+ ),
+ },
+ {
+ title: '6. Nested, custom component child',
+ description:
+ 'A component that renders the className it receives gets the theme. One that drops it silently keeps the outer theme.',
+ code: `
+
+
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '7. Directly nested providers and a function theme',
+ description:
+ 'A provider whose child is another provider passes its class on: the middle theme (primary) and the inner one (secondary) both apply.',
+ code: `
+
+
+
+
+
+ ({ ...outer })}>
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+ ({ ...outer })}>
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '8. Portals',
+ description:
+ 'Dialog, Menu and Tooltip apply the scope to their own root, in a nested provider and in a scoped sibling. useThemeScope() does it for other portals.',
+ code: `
+
+
+
+
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '9. Forced single-scheme nested section',
+ description: 'A nested theme with only a dark scheme stays dark whatever the outer mode is.',
+ code: `
+
+
+
+`,
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: '10. Nested, same variables, then different ones',
+ description:
+ 'With sameVarsTheme (only component defaults change), the child is left untouched. Click the counter, then switch to promoTheme: the child gets the class and keeps its count, so nothing remounts.',
+ code: `
+
+
+
+`,
+ render: () => (
+
+
+
+ ),
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Inspector: reads what ended up in the iframe.
+
+const interestingDeclaration = /color-scheme|--mui-palette-(primary|secondary)-main:/;
+
+function describeRule(cssText: string) {
+ const [selector, body = ''] = cssText.split('{');
+ const declarations = body
+ .replace('}', '')
+ .split(';')
+ .map((declaration) => declaration.trim())
+ .filter(Boolean);
+ const shown = declarations.filter((declaration) => interestingDeclaration.test(declaration));
+ const more = declarations.length - shown.length;
+ return [
+ `${selector.trim()} {`,
+ ...shown.map((declaration) => ` ${declaration};`),
+ ...(more ? [` + ${more} more declarations`] : []),
+ '}',
+ ].join('\n');
+}
+
+function inspect(doc: Document) {
+ const view = doc.defaultView!;
+ const values = (element: Element) => {
+ const style = view.getComputedStyle(element);
+ const read = (name: string) => style.getPropertyValue(name).trim() || '—';
+ return `primary ${read('--mui-palette-primary-main')}, secondary ${read('--mui-palette-secondary-main')}, color-scheme ${style.colorScheme}`;
+ };
+ const scope = (element: Element) => {
+ const classes = Array.from(element.classList)
+ .filter((name) => name.startsWith('mui-theme-') || name === 'light' || name === 'dark')
+ .map((name) => `.${name}`);
+ const attributes = Array.from(element.attributes)
+ .filter(({ name }) => name.startsWith('data-') && name !== 'data-inspect')
+ .map(({ name, value }) => `${name}="${value}"`);
+ return [...classes, ...attributes].join(' ') || 'no scope';
+ };
+
+ const ruleTexts = new Set();
+ Array.from(doc.styleSheets).forEach((sheet) => {
+ Array.from(sheet.cssRules).forEach((rule) => {
+ if (rule.cssText.includes('mui-theme-')) {
+ ruleTexts.add(rule.cssText);
+ }
+ });
+ });
+ const applied: string[] = [];
+ let unused = 0;
+ ruleTexts.forEach((cssText) => {
+ const className = cssText.match(/mui-theme-[a-z0-9]+/)?.[0];
+ if (className && doc.querySelector(`.${className}`)) {
+ applied.push(describeRule(cssText));
+ } else {
+ unused += 1;
+ }
+ });
+
+ const elements = Array.from(doc.querySelectorAll('[data-inspect]')).map((element) => {
+ const scoped = element.closest('[class*="mui-theme-"]') ?? element;
+ return `[${element.getAttribute('data-inspect')}] ${scope(scoped)} → ${values(element)}`;
+ });
+
+ return [
+ ` ${scope(doc.documentElement)}`,
+ `:root → ${values(doc.documentElement)}`,
+ '',
+ ...elements,
+ '',
+ applied.length ? `Scoped rules:\n${applied.join('\n')}` : 'No scoped rules applied',
+ unused ? `\n${unused} rule(s) generated, but no element has the class` : '',
+ ].join('\n');
+}
+
+function Inspector({ frame }: { frame: FrameValue | null }) {
+ const [text, setText] = React.useState('');
+ React.useEffect(() => {
+ if (!frame) {
+ return undefined;
+ }
+ let raf = 0;
+ const update = () => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => setText(inspect(frame.document)));
+ };
+ const observer = new MutationObserver(update);
+ observer.observe(frame.document, { subtree: true, childList: true, attributes: true });
+ update();
+ return () => {
+ observer.disconnect();
+ cancelAnimationFrame(raf);
+ };
+ }, [frame]);
+
+ return (
+
+ {text}
+
+ );
+}
+
+function ScenarioCard({ scenario }: { scenario: Scenario }) {
+ const [frame, setFrame] = React.useState(null);
+ return (
+
+
+ {scenario.title}
+
+
+ {scenario.description}
+
+
+
+
+ {scenario.code}
+
+ {scenario.render()}
+
+
+
+
+ );
+}
+
+export default function ThemeProviderScoping() {
+ return (
+
+
+
+
+ ThemeProvider scoping playground
+
+
+ Temporary page for #49581. Each scenario renders in its own iframe. The right column shows
+ what ended up there: the scope classes and attributes, the computed primary and secondary
+ colors and color scheme, and the scoped CSS rules (palette and color scheme declarations
+ only). Open the browser console for the development warnings and errors.
+
+
+ {scenarios.map((scenario) => (
+
+ ))}
+
+
+
+ );
+}
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..6eea45a0a3d176 100644
--- a/packages/mui-material/src/styles/ThemeProvider.tsx
+++ b/packages/mui-material/src/styles/ThemeProvider.tsx
@@ -1,7 +1,10 @@
'use client';
import * as React from 'react';
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';
@@ -32,6 +35,15 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true
* @default false
*/
forceThemeRerender?: boolean | undefined;
+ /**
+ * 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.
+ * Its mode is only stored when `modeStorageKey` or `colorSchemeStorageKey` is set.
+ * @default false
+ */
+ scoped?: boolean | undefined;
}
: {};
@@ -103,7 +115,21 @@ 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.
+ // The system ThemeProvider only accepts its own props, so the forwarded ones don't reach it.
+ const { className, ...noVarsProps } = props as typeof props & {
+ className?: string | undefined;
+ };
+ Object.keys(noVarsProps).forEach((key) => {
+ if (key.startsWith('data-')) {
+ delete (noVarsProps as Record)[key];
+ }
+ });
+ return (
+
+ {applyScopeToChild(props.children, props)}
+
+ );
}
return ;
}
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..5a02c8ba9b19a4
--- /dev/null
+++ b/packages/mui-material/src/styles/ThemeProviderScoped.test.tsx
@@ -0,0 +1,380 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import * as React from 'react';
+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';
+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();
+
+ afterEach(() => {
+ document.documentElement.classList.remove('light', 'dark');
+ });
+
+ describe('nested', () => {
+ it('adds the scope class to the child when the variables differ', () => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+ 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('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('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(
+
+
+
+
+
+
+
+ ,
+ ),
+ ).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(
+
+
+
+ ,
+ );
+ expect(screen.getByTestId('scope').className).to.equal(
+ screen.getByTestId('scope').parentElement!.className,
+ );
+ });
+});
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..6ada32287b4718 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 {
+ applyScopeToChild,
+ createScopedStyleSheets,
+ getColorSchemeProps,
+ type CssVarsByColorScheme,
+} from './scopeCssVars';
export interface ColorSchemeContextValue<
SupportedColorScheme extends string,
@@ -112,6 +120,15 @@ export interface CreateCssVarsProviderResult<
* @default false
*/
disableStyleSheetGeneration?: boolean | undefined;
+ /**
+ * 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.
+ * Its mode is only stored when `modeStorageKey` or `colorSchemeStorageKey` is set.
+ * @default false
+ */
+ scoped?: boolean | undefined;
}
>,
) => React.JSX.Element;
@@ -212,10 +229,54 @@ export default function createCssVarsProvider<
const defaultColorSchemes = {};
const defaultComponents = {};
+ // 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