Sabbatical dev

sabbatical devtechnical blog
 

Configure Dark Mode in Storybook with MUI 5 and Next.js

Learn about Decorators, Toolbars and more

I recently started a new project and I’ve decided that this time I will return to Material UI for my UI components. I used Tailwind in my last project (this blog) and really liked it, but being extremely familiar with Material UI and thinking about the design I have in mind, I think this time MUI (Material UI) will serve this particular use case very well.

For those who just want to view the examples and the code:

The basic app

The Storybook on Chromatic

The Repo

The last time I used Material UI it was in version 4 and things have slightly changed with MUI 5, for example it now uses Emotion for styling.

I’m using Next.js, and I found that getting everything set up with MUI 5 due to Emotion wasn’t smooth sailing but I found that these two resources below, one video and one post, provided me with everything I needed to get up and running.

Video - What I watched to give me a good idea of what I needed to do.

Post - What I used for copy and pasting

I wanted to include dark mode in this project, as I always appreciate it on other applications. I checked the docs and thankfully it seemed a lot simpler to implement than in version 4.

My implementation pretty much follows this exact implementation in the docs you can find here.

Disclaimer: This post is not really focussing on setting up dark mode and theming etc, but more getting it to work within Storybook, but you can clone and run my repo here if things are unclear.

Once you have a working implementation you should end up with your app wrapped like so:

    <ColorModeContext.Provider value={colorMode}>
      <ThemeProvider theme={theme}>
        <MyApp />
      </ThemeProvider>
    </ColorModeContext.Provider>

I created a simple MUI nav component which contains a switch component for changing the theme.

Here you can see a working version of the application.

Setting up Storybook

I love using Storybook it has so many benefits, from helping me think more about the design of my component, sharing components with the team, visual testing and testing of UI changes in the CI / CD pipeline.

In Storybook I really wanted to be able to switch each component / story to dark mode. I didn't want though to be writing two stories for every component (one in light mode and one in dark) or having to write extra configuration for each story just to achieve this. I wanted just a single global option, configured once.

I couldn't find a concise post that contained all the parts necessary to achieve this, and in case anybody else would like the same functionality I decided to write this post and put all my findings here.

Decorators

So when you wish to provide extra rendering functionality to a story, you need to wrap your story in a decorator. This can be done to individual stories, applied to all stories within a particular component or (as previously said) my choice, the global level, applied the to all stories.

To enable my stories to be configured for dark mode, I needed to configure a global decorator using the MUI ThemeProvider which provides the theme configuration to the components in the application.

However, just wrapping the story in the decorator with this ThemeProvider did not work, but after a lot of digging I came across this post on GitHub which resolved the issue.

So upon reading this post, I ended up with a global decorator in my .storybook/preview.js looking like this.

// .storybook/preview.js

import { ThemeProvider } from '@mui/material/styles';
import { ThemeProvider as EmotionThemeProvider } from 'emotion-theming';
import { createTheme } from '@mui/material';
import GetDesignTokens from '../styles/theme';

const withThemeProvider = (Story, context) => {
  const theme = createTheme(GetDesignTokens('dark'));

  return (
    <EmotionThemeProvider theme={theme}>
      <ThemeProvider theme={theme}>
        <Story />
      </ThemeProvider>
    </EmotionThemeProvider>
  );
};

export const decorators = [withThemeProvider];

The main takeaway was that I needed the Emotion ThemeProvider to wrap the MUI one.

The difference in my implementation from the posts is I had to follow the MUI 5 implementation structure and use the GetDesignTokens function to return my theme.

I could now switch the theme by passing a string parameter in to the GetDesignTokens function, hardcoding it to either 'light' or 'dark'.

I now needed a way to be able to set this string parameter dynamically from Storybook itself.

Toolbars & globals

After a bit of digging I came across toolbars and globals, for those not familiar (or didn't visit the docs via the link above) here's my attempt at a very brief summary:

Storybook provides you with a way to add a custom control to its global toolbar and provide options that can be selected. When you select an option from this control in storybook it will re-render your active story applying any changes from the option selected. You achieve this by accessing this selected option value from within your decorator via a global context object.

If this does not make sense, please look at the link provided to the docs above.

This is exactly what I needed to use to be able to set the string parameter in my decorator from Storybook dynamically.

Following the instructions in the docs, I added this configuration to the .storybook/preview.js:

// addition to .storybook/preview.js

export const globalTypes = {
  theme: {
    name: 'Theme',
    description: 'Global theme for components',
    defaultValue: 'light',
    toolbar: {
      items: ['light', 'dark'],
      showName: true,
    },
  },
};

This configuration is read by Storybook to create a button on Storybook's global (top) toolbar.

The object also applies options to the button, configured from the items array. These options will be selectable in Storybook. The initial value will be set by the defaultValue property on the theme object within the configuration.

Now to access and use this value in our decorator.

Our decorator has a second parameter passed to it called context.
This context parameter is an object, it has a globals object value, and on this is where we will now find the value of our configured theme.

So simply I just pass this value using the context.globals into the GetDesignTokens function on the decorator like so:

const withThemeProvider = (Story, context) => {
  const theme = createTheme(GetDesignTokens(context.globals.theme));

  return (
    <EmotionThemeProvider theme={theme}>
      <ThemeProvider theme={theme}>
        <Story />
      </ThemeProvider>
    </EmotionThemeProvider>
  );
};


When the toolbar button is changed within Storybook and a value selected, the story will be re-rendered and the decorator invoked again using this selected value.

Now I had the component rendered in Storybook, starting with light mode, then when I selected the dark option from my new theme button in the toolbar, my component was updated in its dark theme, perfect!, well for me, not quite.

Personally I also wanted to see the component against the dark mode background taken from theme. Say if a colour contrasts badly against the theme's dark or light background, in Storybook how would we see that?

The background of the canvas is something you cannot control easily from within a story with args or anything, as two are unrelated, but after a lot of digging I found this post, that had a very simple solution.

Basically I just added this line into my decorator:

document.body.style.backgroundColor = theme.palette.background.default;


This sets the Storybook canvas to the current themes background, from our theme config.

I was happy thinking all was done, until I hit the docs tab in Storybook. Unfortunately no background colour in the document section : (

I found this really difficult to fix as the tabs use Javascript to update the markup for each tab, but eventually I found a way.

const targetNode = document.body;
  const config = { childList: true, subtree: true };
  const callback = function (mutationsList, observer) {
    for (let mutation of mutationsList) {
      if (mutation.type === 'childList') {
        if (document.getElementsByClassName('docs-story')[0]) {
          const docs = document.getElementsByClassName('docs-story')[0];
          docs.firstChild.style.backgroundColor = theme.palette.background.default;
        }
      }
    }
  };
  let observer;
  if (observer === undefined) {
    observer = new MutationObserver(callback);
    observer.observe(targetNode, config); // end of setting background on docs
  }


This code basically creates an observer that checks for changes of the DOM. When a change on the child elements occur it looks for an element with a classname of 'docs-story' (which only exists after the event of switching to the docs tab). When it finds this element it then sets its background style to the theme background colour as we did with the canvas.

Finally, I was finished, I could now change all my stories in Storybook to dark mode, so any new stories I create will have dark mode automatically applied. I will never have to think about it again : )

I have posted all links to the app, storybook and repo at the top of the post.


Comments

Add a comment