Enable Tailwind Dark Mode in Next.js and Storybook

6 min read

Nowadays, dark mode on web pages becomes popular because it provides more comfort for some people, including me. Using Tailwind we can configure it easily and choose one of 2 available strategies, the media strategy which leverage preferes-color-scheme CSS media feature and class strategy which use dark class in HTML document. In this article, we will enable Tailwind dark mode in the Next.js project, and also configure Storybook to simulate dark mode in component documentation.

Configure the dark mode

Starting from this step, I assume we already have a project bootstrapped using Next.js and Tailwind. If you need help setting it up, hopefully, this article can be useful. Within this step, we would like to configure the dark mode using class strategy.

  • Configure dark mode class strategy in tailwind.config.js
    module.exports = {
      ...
      darkMode: 'class',
      ...
    };
  • Update styles/global.css to switch background and text color based color mode
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
    body {
      @apply bg-white text-slate-800 dark:bg-slate-900 dark:text-slate-300;
    }

    We use dark: prefix to apply specific style only for dark mode.

  • Also, add some dark mode styling in pages/index.tsx
    import type { NextPage } from 'next';
    import Head from 'next/head';
    
    const Home: NextPage = () => {
      return (
        <>
          <Head>
            <title>Next.js Tailwind</title>
            <meta content="Generated by create next app" name="description" />
            <link href="/favicon.ico" rel="icon" />
          </Head>
    
          <div className="bg-primary-500 absolute left-0 top-64 -z-10 h-72 w-72 rounded-full opacity-10 blur-3xl"></div>
          <div className="bg-danger-500 absolute right-0 top-24 -z-10 h-72 w-72 rounded-full opacity-10 blur-3xl"></div>
    
          <section className="container mx-auto py-16 px-6 text-center sm:py-40">
            <h1 className="mx-auto w-3/4 pb-10 text-4xl font-bold text-slate-900 dark:text-slate-100 sm:text-5xl">
              Next.js Tailwind
            </h1>
            <p>A great combination of Next.js and Tailwind.</p>
          </section>
        </>
      );
    };
    
    export default Home;

    In the above example, dark mode styling is added in h1 tag.

  • Let’s test the configuration by running yarn dev, then add dark class into the HTML.
    Light mode
    Light mode
    Dark mode activated and style with dark: prefix is applied
    Dark mode activated and style with dark: prefix is applied

Create a color mode switcher component

To make it easier for switching between color modes, let’s create a component for it.

  • Create a component named components/ColorModeToggle.tsx
    export interface ColorModeToggleProps {
      className?: string;
      dark?: boolean;
      onChange?: (dark: boolean) => void;
    }
    
    export default function ColorModeToggle({
      className = '',
      dark,
      onChange,
    }: ColorModeToggleProps) {
      return (
        <div
          className={`relative flex h-8 w-20 cursor-pointer rounded-full bg-slate-50 dark:bg-slate-800 ${className}`}
          onClick={() => onChange?.(!dark)}
        >
          <div
            className={`absolute h-8 w-8 rounded-full shadow transition-transform ${
              dark
                ? 'translate-x-12 bg-slate-800 shadow-slate-800 dark:bg-cyan-700 dark:shadow-cyan-700'
                : 'left-0 bg-amber-300 shadow-amber-300'
            }`}
          ></div>
          <div
            className={`w-full px-3 text-sm leading-8 transition-all dark:text-slate-400 ${
              dark ? 'text-left' : 'text-right'
            }`}
          >
            {dark ? 'Dark' : 'Light'}
          </div>
        </div>
      );
    }
  • Create providers/ColorModeProvider.tsx and leverage React Context to save our color mode states as a global state so it can be easily reused across components
    import {
      createContext,
      ReactNode,
      useCallback,
      useEffect,
      useState,
    } from 'react';
    import { useRouter } from 'next/router';
    
    export interface ColorModeContextValue {
      dark: boolean;
      toggleColorMode: (becomeDark: boolean) => void;
    }
    
    export const ColorModeContext = createContext<ColorModeContextValue>({
      dark: false,
      toggleColorMode: () => {
        /*do nothing */
      },
    });
    
    export interface ColorModeProviderProps {
      children?: ReactNode;
    }
    
    export default function ColorModeProvider({
      children,
    }: ColorModeProviderProps) {
      const [dark, setDark] = useState(false);
      const router = useRouter();
    
      const setDarkClassName = useCallback((becomeDark: boolean) => {
        if (becomeDark) {
          global.document.documentElement.classList.add('dark');
        } else {
          global.document.documentElement.classList.remove('dark');
        }
      }, []);
    
      const toggleColorMode = (becomeDark: boolean) => {
        setDark(becomeDark);
        setDarkClassName(becomeDark);
        global.localStorage.theme = becomeDark ? 'dark' : 'light';
      };
    
      useEffect(() => {
        const isCurrentlyDark =
          router.query?.theme === 'dark' || window.localStorage?.theme === 'dark';
        setDark(isCurrentlyDark);
        setDarkClassName(isCurrentlyDark);
      }, [router, setDark, setDarkClassName]);
    
      return (
        <ColorModeContext.Provider
          value={{
            dark,
            toggleColorMode,
          }}
        >
          {children}
        </ColorModeContext.Provider>
      );
    }
  • Put the provider in pages/_app.tsx
    import '../styles/globals.css';
    
    import type { AppProps } from 'next/app';
    
    import ColorModeProvider from 'providers/ColorModeProvider';
    
    function MyApp({ Component, pageProps }: AppProps) {
      return (
        <ColorModeProvider>
    	    <Component {...pageProps} />
        </ColorModeProvider>
      );
    }
    
    export default MyApp;
  • If you find a TypeScript error in pages/_app.tsx saying “Cannot find module ‘providers/ColorModeProvider’” like this, then add baseUrl into tsconfig.json
    TypeScript cannot find providers directory
    TypeScript cannot find providers directory
    {
    	"compilerOptions": {
    	  "baseUrl": ".",
    		...
    	}
    }

    Re-open pages/_app.tsx to see the effect.

  • Create components/Header.tsx to place the toggle component
    import { useContext } from 'react';
    
    import { ColorModeContext } from 'providers/ColorModeProvider';
    
    import ColorModeToggle from './ColorModeToggle';
    
    export default function Header() {
      const { dark, toggleColorMode } = useContext(ColorModeContext);
    
      return (
        <header className="container mx-auto flex items-center justify-end p-6">
          <ColorModeToggle
            className="ml-4"
            dark={dark}
            onChange={toggleColorMode}
          />
        </header>
      );
    }
  • Reuse header component in any pages, for example in pages/index.tsx
    import type { NextPage } from 'next';
    import Head from 'next/head';
    import Header from 'components/Header';
    
    const Home: NextPage = () => {
      return (
        <>
          <Head>
            <title>Next.js Tailwind</title>
            <meta content="Generated by create next app" name="description" />
            <link href="/favicon.ico" rel="icon" />
          </Head>
    
    			<Header />
    
          <div className="bg-primary-500 absolute left-0 top-64 -z-10 h-72 w-72 rounded-full opacity-10 blur-3xl"></div>
          <div className="bg-danger-500 absolute right-0 top-24 -z-10 h-72 w-72 rounded-full opacity-10 blur-3xl"></div>
    
          <section className="container mx-auto py-16 px-6 text-center sm:py-40">
            <h1 className="mx-auto w-3/4 pb-10 text-4xl font-bold text-slate-900 dark:text-slate-100 sm:text-5xl">
              Next.js Tailwind
            </h1>
            <p>A great combination of Next.js and Tailwind.</p>
          </section>
        </>
      );
    };
    
    export default Home;
  • Let’s test the configuration by switching the color mode using our new component.
    Image

Configure dark mode in Storybook

There are several alternatives to enable dark mode in Storybook but I use storybook-dark-mode addon because my plan is to make the whole parts in Storybook UI changed when the color mode is changed and this addon supports it very well.

  • Install dependencies
    yarn add -D storybook-dark-mode
  • Integrate the addon and configure directory aliases in .storybook/main.js
    module.exports = {
    	...
      addons: [
        ...
        'storybook-dark-mode',
      ],
    	webpackFinal: async (config) => {
    		// use tailwind via postcss-loader
        config.module.rules.push({
          test: /\.css$/,
          use: ['postcss-loader'],
          include: path.resolve(__dirname, '../'),
        });
    
    		// define aliases for each directory not provided by Next.js by default
        config.resolve.alias = {
          ...config.resolve.alias,
          components: path.resolve(__dirname, '../components'),
          providers: path.resolve(__dirname, '../providers'),
        };
    
        return config;
      },
    	...
    };
  • Create .storybook/constants.js and put the dark theme color configuration here. We need to share this configuration in multiple files in the next step.
    import { themes } from '@storybook/theming';
    
    export const darkTheme = {
      ...themes.dark,
      appBg: '#0f172a',
      appContentBg: '#0f172a',
      barBg: '#0f172a',
    };

    I use Tailwind’s bg-slate-900 (#0f172a) as background color for dark mode.

  • Create .storybook/DocsContainer.tsx as a custom docs container
    import React from 'react';
    
    import {
      DocsContainer as BaseContainer,
      DocsContainerProps,
    } from '@storybook/addon-docs/blocks';
    import { themes } from '@storybook/theming';
    import { useDarkMode } from 'storybook-dark-mode';
    
    import { darkTheme } from './constants';
    // Thanks to https://github.com/hipstersmoothie/storybook-dark-mode/issues/127#issuecomment-1070524402
    export function DocsContainer({ context, ...props }: DocsContainerProps) {
      const dark = useDarkMode();
    
      const finalProps = {
        ...props,
        context: {
          ...context,
          storyById: (id) => {
            const storyContext = context.storyById(id);
            return {
              ...storyContext,
              parameters: {
                ...storyContext?.parameters,
                docs: {
                  ...storyContext?.parameters?.docs,
                  theme: dark ? darkTheme : themes.light,
                },
              },
            };
          },
        },
      };
    
      return <BaseContainer {...finalProps} />;
    }

    For the time being, storybook-dark-mode addon can’t automatically apply dark mode colors into docs view, so we need this custom container to leverage useDarkMode hook and change the color of docs based on the active color theme. Thanks to the community for providing this solution.

  • Update .storybook/preview.js to configure dark mode and custom docs container
    
    // add the following imports after the existing imports
    import { darkTheme } from './constants';
    import { DocsContainer } from './DocsContainer';
    
    export const parameters = {
    	...
      darkMode: {
        classTarget: 'html',
        dark: darkTheme,
        stylePreview: true,
      },
      docs: {
        container: DocsContainer,
      },
    	...
    };

    Using this configuration, we put dark class in the <html> tag, use the colors we’ve defined earlier, and enable dark mode for canvas and docs view.

  • Test the configuration by running yarn storybook
    Image

Now we have dark mode configured in the web app and Storybook. Read more on Tailwind dark mode documentation for more information about how to use the dark mode. Have a nice day!