Customizing the MUI Palette Tones

Hello, this is Chandler from Progate with the 17th day of the Progate Advent Calendar.

I work as a frontend engineer, and recently I've been getting acquainted with MUI v5, which is a UI component library. These kinds of libraries are great for keeping things consistent and also streamlining development but can come with limitations when customizing them to integrate with your own design system.

I've actually worked with MUI back a few years ago back when it was Material-UI v0.20.2, and I have to say it's come a long way. Back then there wasn't really a way to customize the components yourself as inline styling can't reach the inner elements, and I remember there was a lot of abusing the CSS !important property. With the new MUI, though, component customization has become a lot more accessible. You can use the sx prop or styled-components to pass CSS to components on an individual level, and you can even override component styles on a global scale in the theme as documented here.

While components have become very customizable, I think there are still some restrictions with general theming. One problem I have run into is with the palette. The palette is pretty customizable as you can change the default colors and add new ones, but there's still more that I wish I could do with it. Each color has four variants main, light, dark, and contrastText - the dark and light are tones calculated based on main using tonalOffset. But what if I want to add more tones?

For example, if I want to add extraLight to the primary color, I can do something like this:

let theme = createTheme({
    palette: {
      primary: {
        main: blue[700],
        extraLight: blue[300],
      },
...})

But, there are two things I don't like about this - First, I have to redefine the main for primary. That isn't too big of a deal though as it would only have to be done for the default colors you want to keep. And second, I would have to define extraLight for each color, even though this is something I would like to be calculated automatically for me with tonalOffset

I think it would be nice if on the palette object, I could do something like this:

palette: {
  tonalOffset: 0.2,
    tones: {
      light: {
        light: 1, // tonalOffset * 1 → 0.2 lighter than main
        extraLight: 2, // tonalOffset * 2 → 0.4 lighter than main
      }, 
      dark: {
        dark: 1, // tonalOffset * 1 → 0.2 darker than main
        extraDark: 2, // tonalOffset * 2 → 0.4 darker than main
      }
   }
}

The tones have keys for light and darken to use lighten and darken, respectively, and the keys within them refer to the name of the tone. The values are the degree of shift to use with tonalOffset for calculating the color.

However, based on the documentation, it doesn't look like there's a way to do something like this with the capabilities of the current palette settings. Taking a look under the hood at how the dark and light tones are generated, I found that they are generated here with addLightAndDark within augmentColor.

It also looks like while augmentColor may be accessible on the palette object in the theme, you can't adjust it or set it yourself.

While I can't get the extraLight tone automatically generated using the existing settings, one thing I can do is generate the tones myself and merge it with my theme. Basically, I can create an incomplete theme object that only contains the palette with all of the extraLight tones and then merge this with my theme.

const generateExtraLightTones = (theme) => {
  const paletteObject = {}
  for (const paletteKey of Object.keys(theme.palette)) {
    const main = theme.palette[paletteKey]['main']
    if (!main) {continue}
    const extraLight = lighten(main, theme.palette.tonalOffset * 2)
    paletteObject[paletteKey] = {}
    paletteObject[paletteKey].extraLight = extraLight
  }
  return {palette: paletteObject}
}

This will go through all the keys in the palette object and calculate the extraLight tones based on the main color. As I said above, I want to calculate this based on the tonalOffset, so I also grab that from the existing palette and use lighten to calculate extraLight.

Not all values in the palette object are color objects, so I need a way to make sure I'm only adding extraLight to actual colors. Since only color objects will have a main key, I simply check for this, but of course, this could be checked more strictly with the other object keys to make sure that only the colors are being grabbed.

Just for a clear image, this will create something like this:

{
  palette: {
    primary: { 
      extraLight: rgb(175, 119, 175),
    },
    ...
  } 
}

And then it can be simply merged with the theme like this:

createTheme(theme, generateExtraLightTones(theme))

Now I can have all the extraLight tones calculated for me.

This solves the current issue with extraLight, but it would be nicer to consider the possibility of having more tones, such as extraDark. For that, I could create something similar to the settings I originally hoped I could pass to the palette. This would be an object with the tones, separated by if they use lighten or darken, and refer to the shift degree with tonalOffset.

const extraPaletteTones = {
  light: {
    extraLight: 2, // tonalOffset * 2
  },
  dark: {
    extraDark: 2, // tonalOffset * 2
  }
}

Then instead of calculating just the extraLight tone, I can calculate the tones set in extraPaletteTones.

const generateExtraPaletteTones = (theme) => {
  const paletteObject = {}
  for (const paletteKey of Object.keys(theme.palette)) {
    const main = theme.palette[paletteKey]['main']
    if (!main) {continue}
    paletteObject[paletteKey] = {}
    for (const lightTone of Object.keys(extraTones.light)) {
      const lightToneShiftValue = extraTones.light[lightTone]
      paletteObject[paletteKey][lightTone] = lighten(main, lightToneShiftValue * theme.palette.tonalOffset)
    }
    for (const darkTone of Object.keys(extraTones.dark)) {
      const darkToneShiftValue = extraTones.dark[darkTone]
      paletteObject[paletteKey][darkTone] = darken(main, darkToneShiftValue * theme.palette.tonalOffset)
    }
  }
  return {palette: paletteObject}
}

And that's pretty much it. Now I can add more tones types if I want and they'll be automatically calculated and set rather than having to set them individually myself.

Even though I've come across roadblocks like this, all in all, it has been really smooth using MUI so far. I'm looking forward to developing more with it and learning more.