Design Tokens integration with MUI: Tutorial and Example Application for Frontend Developers
It is recommended to check the intro article before diving in specifics of the current guide. Source code for this and other example applications can be found on Github.
Overview and Prerequisites
Material UI is the fully-loaded component library that features a suite of customization options that make it easy to implement your own custom design system. Practically speaking however, it needs quite an introduction, or better say disambiguation.
Material UI is a part of the extensive MUI ecosystem, specifically MUI Core. Along with Material UI for our demonstration we’ll be also using:
- Base UI - library of headless React UI components and low-level hooks
- MUI System - collection of CSS utilities for rapidly laying out custom designs with MUI component libraries
So effectively we’ll be using Material-Base-System-UI hybrid. For simplicity we’ll be referring to it further as Material UI, specifying certain technologies where needed.
Material UI installation, but more importantly setup, takes several steps. Let’s begin with installation of all required packages:
npm install @mui/material @mui/system @mui/base @emotion/react @emotion/styled
Note, that this snippet installs all aforementioned modules. If your project needs are different, please refer to the official documentation for granular setup instructions.
Next step is to wrap our application in the theme provider component. Here your have several options, however since we’ll be heavily relying on CSS variables, we’ll be using CssVarsProvider
as the provider:
// excerpt from main.tsx
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<CssVarsProvider theme={theme}>
<ThemeProvider>
<App />
</ThemeProvider>
</CssVarsProvider>
</React.StrictMode>
);
It’s a standard approach with a tiny difference being using CSS variables provider in favor of more traditional ThemeProvider
from the same package @mui/material/styles
.
For more information on CSSVarsProvider
check out official docs.
Next step is to create and customize the theme
that we just passed to CssVarsProvider
.
// excerpt from theme.ts
import { experimental_extendTheme as extendTheme } from '@mui/material/styles';
const theme = extendTheme({
colorSchemes: {
light: {
palette: {},
},
},
typography: {},
spacing: {},
shadows: {},
});
We’ll be looking in details into structure of theme.ts
and related resources in the following section. For now, let’s dive in and create a couple of components!
Explore the source code in DTG Examples repository.
Current guide refers to the following dependencies versions, latest at the moment of writing:
"@dtg-examples/common-tokens": "1.0.0",
"@emotion/react": "11.11.1",
"@emotion/styled": "11.11.0",
"@mui/base": "5.0.0-beta.5",
"@mui/material": "5.13.6",
"@mui/system": "5.13.6",
"vite": "4.3.9"
Typical Component
To begin with let’s create a simple presentational component. It’s worth mentioning, that we’ll not be heavily relying on Material UI as the technology/library, in favor of more generic solutions - Base UI and MUI System. Instead of taking more traditional theme customization approach, we’ll be experimenting with unstyled components in the first place.
It’s worth noting that our demo project employs a mix of techniques to build UI. This is merely for the sake of experiment and in conventional projects it’s worth to stick to one approach for consistency. I wouldn’t generally recommend to mix building a theme from scratch with overriding a default theme.
For the basic demonstration let’s create a simple presentational component:
// excerpt from Header.tsx
import Box from '@mui/system/Box';
import Stack from '@mui/system/Stack';
const Header = (props: HeaderProps) => {
const { children } = props;
return (
<Stack
component="header"
direction="row"
alignItems="center"
>
<Box component="h1">
Dystopian Weather
</Box>
<div>{children}</div>
</Stack>
);
};
Currently, component does not have any styling, except the layout attributes coming from Stack component used as header
and generic Box used as h1
.
Check out the same component with all styling props in place:
// excerpt from Header.tsx
import Box from '@mui/system/Box';
import Stack from '@mui/system/Stack';
import { theme } from '../../theme';
const Header = (props: HeaderProps) => {
const { children } = props;
return (
<Stack
component="header"
direction="row"
alignItems="center"
sx={{
position: 'relative',
zIndex: 20,
py: 8,
px: 4,
bgcolor: theme.vars.palette.base.dark,
color: theme.vars.palette.contrast.dark,
borderBottom: `${theme.spacing(2)} solid`,
borderColor: `rgba(${theme.vars.palette.primary.mainChannel}, 0.5)`
}}
>
<Box component="h1" sx={{ m: 0, flexGrow: 1}}>
Dystopian Weather
</Box>
<div>{children}</div>
</Stack>
);
};
Evidently, styling is implemented via sx
prop, being a superset of CSS and using theme-aware properties along with the native CSS values. For instance, bgcolor
stands for background-color
and in this examples uses base dark color (var(--awsm-color-base-dark)
) from our customized theme.
MUI does not provide a smooth API to work with alpha-channel, however auto-generated .mainChannel
property can be used to achieve similar results. For example with borderColor
here:
borderColor: `rgba(${theme.vars.palette.primary.mainChannel}, 0.5)`
Note that there are certain performance considerations for using
sx
prop in abundance. However unless your application is somewhat large and/or required to render 1000-s of items, you should be in the clear.
Let’s have a look at something more complex. To create our List (cities list) component we’ll be using Select from Base UI. Select is using Popper
and of course Option
components under the hood, so we’ll need to update those as well.
Note that this selection of elements is highly experimental. The main reason for such approach is that at the time of writing this guide Radio Button component was (is?) in development.
It’s recommended to study the source code for this example, but we can highlight a couple of points of interest.
To create a List using Select we need several composites - list of items, item itself and a container that holds everything.
Here’s how a container is formed. We need to make it static, hence the !important
overrides for inline styles. Notice that this approach is relying on styled
wrapper - one of the several ways to handle the styling in Base UI:
// excerpt from List.tsx
import MUIPopper from '@mui/base/Popper';
// line 38
const ListDropDown = styled(MUIPopper)({
position: 'relative !important',
transform: 'none !important',
boxSizing: 'border-box',
width: '100%',
display: 'flex',
flexFlow: 'column',
});
ListDropDown.defaultProps = {
disablePortal: true,
};
Check out another snippet. This time for the list item, based on Option component. Notice that for styling element states we’re relying on .Mui-*
classnames:
// excerpt from List.tsx
import MUIOption from '@mui/base/Option';
// line 72
const ListOption = styled(MUIOption)({
flex: '1 1 auto',
padding: `${theme.spacing(3)} ${theme.spacing(4)}`,
listStyle: 'none',
background: theme.vars.palette.secondary.main,
color: theme.vars.palette.secondary.contrastText,
transition: theme.transitions.create(['all'], {
duration: theme.transitions.duration.short,
}),
['&.Mui-selected']: {
background: theme.vars.palette.secondary.contrastText,
color: theme.vars.palette.secondary.main,
cursor: 'default',
},
['&.MuiOption-highlighted:not(.Mui-selected)']: {
background: theme.vars.palette.secondary.light,
color: theme.vars.palette.secondary.contrastText,
cursor: 'default',
},
// ...
});
The rest of the code is self-explanatory with some help of official examples:
// excerpt from List.tsx
const List = (props: ListProps) => {
const { name, value, items, onSelectValue, grow } = props;
const slots: MUISelectProps<string, false>['slots'] = {
root: ListTrigger,
popper: ListDropDown,
listbox: ListBox,
};
return (
<SelectRoot
grow={grow}
name={name}
value={value}
onChange={(_, value) => {
onSelectValue(value as string);
}}
slots={slots}
listboxOpen
>
{items.map(({ uid, city, code, temp }) => (
<ListOption value={uid} key={uid}>
<Box
typography="h3"
sx={{ lineHeight: theme.typography.body1.lineHeight }}
>
{city}
</Box>
<Box typography="body1">
{weather[code]}: {temp}°C
</Box>
</ListOption>
))}
</SelectRoot>
);
};
There’s only one particular prop that looks a bit alien to the whole structure - grow
.
To understand the reasoning for that prop we need to dive into the principles and concepts of composition in Material UI. It’s a topic for another big article, so to put it simply, in the current scenario we are not able to pass arbitrary CSS from the parent component level. To fully understand the difference check out similar solutions for Headless UI or Chakra UI.
Instead we’re relying on a custom prop, which can be set on a parent level and which can be respectively customized on the component level. To get a hold of the snippet in full I highly recommend to check out the official docs.
// excerpt from List.tsx
import MUISelect, { SelectProps as MUISelectProps } from '@mui/base/Select';
// line 16
export interface ListProps extends ComponentProps {
name: string;
value: string;
items: Weather[];
onSelectValue: (v: string) => void;
grow?: boolean;
}
// line 108
const SelectRoot = styled(MUISelect, {
shouldForwardProp: (prop) => prop !== 'grow',
})(({ grow }) => ({
['~ .MuiSelect-popper']: {
flex: grow && '1 0 auto',
display: grow && 'flex',
flexFlow: grow && 'column',
justifyContent: grow && 'center',
},
})) as (
props: MUISelectProps<string, false> & Pick<ListProps, 'grow'>
) => JSX.Element;
Finally it’s time to delve into theme
setup and explore the customization and configuration options for MUI.
Design Tokens Integration
Theme configuration for Material UI is not straightforward, but it’s not complex either. It’s somewhat different from the setup in other frameworks. Perhaps it is related to the usage of CssVarsProvider
, experimental at the moment of writing this guide. But I tend to think it’s a historical matter.
Since we’re using Typescript, we need to make one small but meaningful import in our types according to the docs:
import type {} from '@mui/material/themeCssVarsAugmentation';
Our theme setup can roughly be split into several parts:
- palette tokens
- spacing tokens
- shadow tokens
- the rest
Let’s explore each part. Welcome to explore theme.ts configuration beforehand or along the way.
The most representative part - palette (or color) tokens. In addition to modifying existing schema (i.e. primary.light
) Material UI allows to add custom fields. However you would need to support that in Typescript for full compatibility. Not only that, but since we’re using CssVarsProvider
, MUI creates a CSS variable for every color property we add. Knowing that we can simply re-assign variables in our CSS.
Here’s how palette setup looks altogether.
In theme.ts
we are setting placeholders for the colors:
// excerpt from theme.ts
const theme = extendTheme({
// ----------------------------------
// palette
// ----------------------------------
colorSchemes: {
light: {
palette: {
// extending existing schema
primary: {
main: '#000',
light: '#000',
dark: '#000',
// this one is custom
tone: '#000',
},
// adding custom fields
// all fields are custom
base: {
light: '#000',
dark: '#000',
},
}
}
}
});
The new properties should be supported accordingly by Typescript.
For that we’ll need to augment the typings. Check out source code, specifically index.d.ts
:
// excerpt from index.d.ts
declare module '@mui/material/styles' {
interface SimplePaletteColorOptions extends MUISimplePaletteColorOptions {
tone: string;
}
interface CommonPaletteColorOptions {
light: string;
dark: string;
}
interface PaletteOptions extends MUIPaletteOptions {
base: CommonPaletteColorOptions;
}
interface Palette extends MUIPalette {
base: CommonPaletteColorOptions;
}
}
Finally, let’s assign proper values to our defined fields.
This should take place in CSS, in our case in app-specific index.css:
/* excerpt from index.css */
html:root {
--mui-palette-primary-main: var(--awsm-color-primary);
--mui-palette-primary-light: var(--awsm-color-primary-tint);
--mui-palette-primary-dark: var(--awsm-color-primary-shade);
--mui-palette-primary-tone: var(--awsm-color-primary-tone);
--mui-palette-primary-contrastText: var(--awsm-color-primary-contrast);
--mui-palette-primary-mainChannel: var(--awsm-color-primary-rgb);
--mui-palette-base-light: var(--awsm-color-base-light);
--mui-palette-base-light-mainChannel: var(--awsm-color-base-light-rgb);
--mui-palette-base-dark: var(--awsm-color-base-dark);
--mui-palette-base-dark-mainChannel: var(--awsm-color-base-dark-rgb);
}
Now we can go back to the code snippet and finally make sense out of semantic theme values. Values from the theme
object would eventually render to our recently assigned CSS variables:
bgcolor: theme.vars.palette.base.dark // -> var(--awsm-color-base-dark)
The hardest part is over! Everything else is much more streamlined.
What about spacing? Material UI allows different strategies to facilitate your custom spacing system. We’ll be using numeric approach, similar to Tailwind CSS setup:
// excerpt from theme.ts
const DTGSpacingMap = new Map();
DTGSpacingMap.set(0, 0);
DTGSpacingMap.set(0.5, 'var(--awsm-space-012)');
DTGSpacingMap.set(1, 'var(--awsm-space-025)');
DTGSpacingMap.set(1.5, 'calc(var(--awsm-space-025) + var(--awsm-space-012))');
DTGSpacingMap.set(2, 'var(--awsm-space-050)');
DTGSpacingMap.set(3, 'var(--awsm-space-075)');
const theme = extendTheme({
// line 182
spacing: (factor: number) => DTGSpacingMap.get(factor),
});
To use spacing tokens in code we’ll need to either use a numeric value or call a function with the same number, depending on the styling approach. Result would be the same:
// using spacing-2 token
// CSS equivalent:
// { padding: var(--awsm-space-050); }
<Box
component="section"
sx={{ p: 2 }}
/>
// using spacing-3 token
// CSS equivalent:
// { padding: var(--awsm-space-075); }
const OtherBox = styled('div')({
padding: theme.spacing(3),
});
Let’s take care of the shadows. They stand aside of all other tokens and rather rigid in terms of customization. I suggest couple of methods to dealing with shadow tokens.
Method 1 is sector mapping. It’s rather head-on, but does the job. The main reason is compatibility with default MUI theme.
// excerpt from theme.ts
const theme = extendTheme({
shadows: [
'none',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-small)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-medium)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
'var(--awsm-shadow-large)',
],
});
Method 2 can be recommended if you are not intended to use default MUI styling, since we’ll be setting up custom fields. It is much more clear and streamlined:
// excerpt from theme.ts
const theme = extendTheme({
// IMPORTANT
// mind the property name
shadow: {
small: 'var(--awsm-shadow-small)',
medium: 'var(--awsm-shadow-medium)',
large: 'var(--awsm-shadow-large)',
},
});
Now for the typings:
// excerpt from index.d.ts
declare module '@mui/material/styles' {
interface CssVarsThemeOptions extends MUICssVarsThemeOptions {
shadow: {
small: string;
medium: string;
large: string;
};
}
interface Theme extends MUITheme {
shadow: {
small: string;
medium: string;
large: string;
};
}
}
The very same approach can be used to add custom token properties if needed. For instance, this is how radius
, duration
and focus
are set up in my configuration. All other tokens are either similar or self-explanatory. Feel free to use this config as a boilerplate for your project needs!
Conclusion
Styling customization of Material UI is different; best approach would depend on selected module (Material UI, Base UI, …), styling options that module provides (theme override, headless solution, styled wrapper, …) and of course, your project needs and experience.
Using Material UI (Base UI + MUI System) with tokens created by Design Tokens Generator takes couple of steps and yields great results.
Material UI constantly evolves and each new module has it’s audience and goals. Supercharging MUI with Design Tokens is possible both for theme overrides and headless approach. Sometimes it’s not very straightforward due to branched module ecosystem with their own features, but feasible nevertheless.
If you are new to MUI world, it’s recommended to start with the demo project as a boilerplate. Also feel free to experiment with the example theme file or use it as reference for future projects.
To compare Material UI with alternatives, more suitable your project needs, welcome to explore the guides on Chakra UI, Radix UI and Headless UI.