Another Dark Mode Post: MUI


Uncategorized

Updated May 2nd, 2023

See the Next JS post for the CSS variable / body[data-attribute=”dark”] approach here. Also may want to read Josh C0meau’s “Quest for the perfect dark mode” post here.

Youtuber “Dev next door” had a good case for using next-theme, (video here). Next-theme is a small library that does what you need and has some great pointers in the docs about dark mode issues overall. They add an attribute to the html tag and have there own Theme Provider that you can wrap around MUI’s theme provider. Here is the script they inject into the HEAD tag.

!(function () {
  try {
    var d = document.documentElement,
      n = "data-theme",
      s = "setAttribute"
    var e = localStorage.getItem("theme")
    if ("system" === e || (!e && true)) {
      var t = "(prefers-color-scheme: dark)",
        m = window.matchMedia(t)
      if (m.media !== t || m.matches) {
        d.style.colorScheme = "dark"
        d[s](n, "dark")
      } else {
        d.style.colorScheme = "light"
        d[s](n, "light")
      }
    } else if (e) {
      d[s](n, e || "")
    }
    if (e === "light" || e === "dark") d.style.colorScheme = e
  } catch (e) {}
})()

The code is terse by design but if you replace the variables:

!(function () {
  try {
    if ("system" === localStorage.getItem("theme") || (!localStorage.getItem("theme") && true)) {
      if (window.matchMedia("(prefers-color-scheme: dark)").media !== "(prefers-color-scheme: dark)" || window.matchMedia("(prefers-color-scheme: dark)").matches) {
        document.documentElement.style.colorScheme = "dark"
        document.documentElement["setAttribute"]("data-theme", "dark")
      } else {
        document.documentElement.style.colorScheme = "light"
        document.documentElement["setAttribute"]("data-theme", "light")
      }
    } else if (localStorage.getItem("theme")) {
      document.documentElement["setAttribute"]("data-theme", localStorage.getItem("theme") || "")
    }
    if (localStorage.getItem("theme") === "light" || localStorage.getItem("theme") === "dark") document.documentElement.style.colorScheme = localStorage.getItem("theme")
  } catch (e) {}
})()

Note: The dev next door video shows a simple explanation that is refactored on the linked GitHub link in the video description. To presumably keep the “_app.js” clean he basically moved next-theme’s “ThemeProvider” into the Page Provider component along with the emotion cache. This then wraps a “MUIThemeProvider” component which is what essentially was the PageProvider component in the video. Confusing yes but makes sense.

Ed Roh Video

Ed Roh showed the best implementation using MUI here. But this project uses create-react-app so doesn’t get into the adjustments needed for NEXT JS. These adjustments include handling the existing user/system preference via a function in “_document.js and getting that result into state but via dynamic import, bypassing the server to get access to the document.

The “getting that result into state” on NextJS is harder than you would think. Context cannot leverage the “document.body.dataset.theme” as the default value so you also end up with this flickering. The reason for this is the code runs on the server in which the window and document are undefined and so must default to light or dark because you can’t access local storage or the document. I tried using the dynamic import for useMode and ColorMode Context but they are not components so this didn’t work.

Note: is there an implementation where we put this in a component that can be dynamically imported? The dynamic import skips the server side render. It is explained in the electric animal’s post. The issue here is the “useMode” custom hook, which returns the theme and the updating function, is not a component and cannot be dynamically imported. I thought about creating a component wrapper but bailed on this strategy before trying.

Alternative implementations include using next/themes library, (here). You could also explore leveraging cookies.

An overview of the Ed Roh process: There is a theme.js file that sets up the MUI palette, creates the context and defines a custom hook. The app.js file extracts the wraps the app in context providers and the MUI theme provider. The TopBar component consumes the theme (which has access to theme/mode via theme.palett.mode) and has access to the update function (colorMode.toggleColorMode). In this file there is no useEffect and no useState.

The MUI theme itself has 4 base colors with variants off of that refering the big objects with shades. This seems like overkill but is needed for non-default MUI theme colors.

A few things to note:

This uses the themeprovider context approach leverages a custom “useMode” hook and context built in the theme.js file.

import { createContext, useState, useMemo } from "react";
import { createTheme } from "@mui/material/styles";
// color design tokens export
export const tokens = (mode) => ({
  ...(mode === "dark"
    ? {
        grey: {
          100: "#e0e0e0",
          200: "#c2c2c2",
          300: "#a3a3a3",
          400: "#858585",
          500: "#666666",
          600: "#525252",
          700: "#3d3d3d",
          800: "#292929",
          900: "#141414",
        },
        primary: {
          100: "#d0d1d5",
          200: "#a1a4ab",
          300: "#727681",
          400: "#1F2A40",
          500: "#141b2d",
          600: "#101624",
          700: "#0c101b",
          800: "#080b12",
          900: "#040509",
        },
        greenAccent: {
          100: "#dbf5ee",
          200: "#b7ebde",
          300: "#94e2cd",
          400: "#70d8bd",
          500: "#4cceac",
          600: "#3da58a",
          700: "#2e7c67",
          800: "#1e5245",
          900: "#0f2922",
        },
        redAccent: {
          100: "#f8dcdb",
          200: "#f1b9b7",
          300: "#e99592",
          400: "#e2726e",
          500: "#db4f4a",
          600: "#af3f3b",
          700: "#832f2c",
          800: "#58201e",
          900: "#2c100f",
        },
        blueAccent: {
          100: "#e1e2fe",
          200: "#c3c6fd",
          300: "#a4a9fc",
          400: "#868dfb",
          500: "#6870fa",
          600: "#535ac8",
          700: "#3e4396",
          800: "#2a2d64",
          900: "#151632",
        },
      }
    : {
        grey: {
          100: "#141414",
          200: "#292929",
          300: "#3d3d3d",
          400: "#525252",
          500: "#666666",
          600: "#858585",
          700: "#a3a3a3",
          800: "#c2c2c2",
          900: "#e0e0e0",
        },
        primary: {
          100: "#040509",
          200: "#080b12",
          300: "#0c101b",
          400: "#f2f0f0", // manually changed
          500: "#141b2d",
          600: "#1F2A40",
          700: "#727681",
          800: "#a1a4ab",
          900: "#d0d1d5",
        },
        greenAccent: {
          100: "#0f2922",
          200: "#1e5245",
          300: "#2e7c67",
          400: "#3da58a",
          500: "#4cceac",
          600: "#70d8bd",
          700: "#94e2cd",
          800: "#b7ebde",
          900: "#dbf5ee",
        },
        redAccent: {
          100: "#2c100f",
          200: "#58201e",
          300: "#832f2c",
          400: "#af3f3b",
          500: "#db4f4a",
          600: "#e2726e",
          700: "#e99592",
          800: "#f1b9b7",
          900: "#f8dcdb",
        },
        blueAccent: {
          100: "#151632",
          200: "#2a2d64",
          300: "#3e4396",
          400: "#535ac8",
          500: "#6870fa",
          600: "#868dfb",
          700: "#a4a9fc",
          800: "#c3c6fd",
          900: "#e1e2fe",
        },
      }),
});
// mui theme settings
export const themeSettings = (mode) => {
  const colors = tokens(mode);
  return {
    palette: {
      mode: mode,
      ...(mode === "dark"
        ? {
            // palette values for dark mode
            primary: {
              main: colors.primary[500],
            },
            secondary: {
              main: colors.greenAccent[500],
            },
            neutral: {
              dark: colors.grey[700],
              main: colors.grey[500],
              light: colors.grey[100],
            },
            background: {
              default: colors.primary[500],
            },
          }
        : {
            // palette values for light mode
            primary: {
              main: colors.primary[100],
            },
            secondary: {
              main: colors.greenAccent[500],
            },
            neutral: {
              dark: colors.grey[700],
              main: colors.grey[500],
              light: colors.grey[100],
            },
            background: {
              default: "#fcfcfc",
            },
          }),
    },
    typography: {
      fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
      fontSize: 12,
      h1: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 40,
      },
      h2: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 32,
      },
      h3: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 24,
      },
      h4: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 20,
      },
      h5: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 16,
      },
      h6: {
        fontFamily: ["Source Sans Pro", "sans-serif"].join(","),
        fontSize: 14,
      },
    },
  };
};
// context for color mode
export const ColorModeContext = createContext({
  toggleColorMode: () => {},
});
export const useMode = () => {
  const [mode, setMode] = useState("dark");
  const colorMode = useMemo(
    () => ({
      toggleColorMode: () =>
        setMode((prev) => (prev === "light" ? "dark" : "light")),
    }),
    []
  );
  const theme = useMemo(() => createTheme(themeSettings(mode)), [mode]);
  return [theme, colorMode];
};

In the “scenes/global/topbar” component.

import { Box, IconButton, useTheme } from "@mui/material";
import { useContext } from "react";
import { ColorModeContext, tokens } from "../../theme";
const Topbar = () => {
  const theme = useTheme();
  const colors = tokens(theme.palette.mode);
  const colorMode = useContext(ColorModeContext);
  return (
      <Box display="flex">
        <IconButton onClick={colorMode.toggleColorMode}>
          {theme.palette.mode === "dark" ? (
            <DarkModeOutlinedIcon />
          ) : (
            <LightModeOutlinedIcon />
          )}
        </IconButton>
      </Box>
  );
};
export default Topbar;

tailwind shades extension to get shades of 5 base colors

In the App.ts file:

import { CssBaseline, ThemeProvider } from "@mui/material";
import { ColorModeContext, useMode } from "./theme";
function App() {
  const [theme, colorMode] = useMode();
  return (
    <ColorModeContext.Provider value={colorMode}>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <div className="app">
            <Topbar setIsSidebar={setIsSidebar} />
        </div>
      </ThemeProvider>
    </ColorModeContext.Provider>
  );
}
export default App;