20.5 Theme Component Stacking: Multiple Themes
Right, so you’ve got a theme. Maybe it’s a nice, sensible one from the community. But now you want to change something. A button color here, a font there. Your first instinct might be to crack open the theme’s source files and start hacking away. Please, for the love of all that is maintainable, don’t do that. You’ll create a “snowflake” project that can never be updated again without your customizations shattering into a million pieces.
The sane way to do this is by stacking another theme on top of it. Think of it like layers. You start with a base layer, say theme_a, which provides all the sensible defaults. Then you create your own, ridiculously-named theme (theme_my_amazing_modifications) that inherits from it. Your theme only contains the bits you want to change. The underlying engine—Material-UI, in our case—is then smart enough to merge these layers together, with your customizations taking precedence. It’s the CSS cascade, but for your entire component design system.
How the Stack Actually Works
The magic happens in the ThemeProvider. You don’t just provide a theme; you can provide a stack of themes. The library merges them from the bottom up, meaning the last theme you provide can override values from the first.
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { deepmerge } from '@mui/utils';
// Your base theme - probably from @mui/material or another package
const baseTheme = createTheme({
palette: {
primary: { main: '#1976d2' },
secondary: { main: '#dc004e' },
},
});
// Your customizations - ONLY the things you want to change
const myCustomTheme = createTheme({
palette: {
primary: { main: '#ff6f00' }, // Orange, because why not?
background: { default: '#f5f5f5' },
},
components: {
MuiButton: {
styleOverrides: {
root: { borderRadius: 8 },
},
},
},
});
// This is the manual, "I'm in control" way to merge them.
// The `options` argument is key for merging the `components` config deeply.
const mergedTheme = createTheme(baseTheme, myCustomTheme);
function App() {
return (
<ThemeProvider theme={mergedTheme}>
{/* Your app goes here */}
</ThemeProvider>
);
}
But here’s the pro-tip: you rarely need to do the merge yourself. The ThemeProvider is perfectly happy to take an array of themes and handle the merging internally. This is cleaner and more declarative.
function App() {
return (
<ThemeProvider theme={[baseTheme, myCustomTheme]}>
{/* Your app goes here */}
</ThemeProvider>
);
}
The merging logic is smart. For simple objects like palette, it does a shallow merge (with some special handling for palette colors). For more complex structures like components, it uses a deep merge. This means you can override a single style in a variant without having to redefine the entire variant object.
The Pitfalls of Palette Merging
This is where everyone gets tripped up. The merge is shallow for the palette. Look at our example above. The baseTheme had palette.primary.main and palette.secondary.main. Our myCustomTheme overrode primary.main and added a background.default.
The result? The merged theme’s secondary object is the entire object from the base theme. It didn’t merge the secondary objects; it just took the top-level one from the last theme that defined it. This is usually what you want, but it’s crucial to understand.
If your base theme defines palette.text.disabled and your custom theme doesn’t mention palette.text at all, the entire text object from the base theme is used. If your custom theme defines any palette.text value, it must provide the entire text object structure you want, or you’ll accidentally wipe out the other values. It’s the one part of this process that feels a bit clunky, but you just have to be mindful of it.
Overriding Component Styles Deep in the Stack
This is the real superpower. Your base theme (theme_a) might have a complex configuration for the MuiCard component. You come along with theme_b and decide you only hate the padding. You don’t need to copy the entire MuiCard theme from theme_a; you just override the one thing.
// In your overriding theme
const myCustomTheme = createTheme({
components: {
MuiCard: {
styleOverrides: {
root: ({ theme }) => ({
// This will override the root style from the base theme
padding: theme.spacing(3),
// ...but other styles from the base theme's root (like border, background color)
// are preserved because of the deep merge. Magic.
}),
},
},
},
});
The deep merge ensures your root style override is combined with the base theme’s root style, not just replacing it wholesale. It’s incredibly powerful and allows for surgical precision.
Best Practice: Use a TypeScript Project? Define Your Theme Augmentation
If you’re using TypeScript and you add custom properties to your palette or theme (like palette.superDanger), the type system will, rightly, throw a fit. You need to tell TypeScript about your plans for world domination. Do this once in a central theme.d.ts file.
// theme.d.ts
import { Theme } from '@mui/material/styles';
declare module '@mui/material/styles' {
interface Palette {
superDanger: Palette['primary'];
}
interface PaletteOptions {
superDanger?: PaletteOptions['primary'];
}
// If you also added a new theme spacing value, you'd do it here:
interface Theme {
spacing: (value: number) => string;
}
}
This isn’t just pedantry; it’s what makes your custom theme auto-complete and type-safe everywhere, from styled components to useTheme. It’s the difference between a helpful colleague and a cryptic error message. Always do it.