DEV Community

Cover image for Implement Dark/Light mode: How to fix the flicker of incorrect theme?
TusharShahi
TusharShahi

Posted on • Edited on

Implement Dark/Light mode: How to fix the flicker of incorrect theme?

Some time ago, I created my portfolio website using React + Next.js. I also added a dark mode toggle switch. 💡

Recently, I found some free time to look at the functionality again. The switch works well but the initial load suffers from a problem. There is a flash of incorrect theme when the page loads for a very small time. The flash can be more noticeable on different devices and network connections.

Webpage loading in a throttled environment

Below is a write up of how I fixed it for my particular case.

The article does not go over the basics of how to create a dark mode switch using React (and/or Next.js) with localStorage. There are other brilliant articles for that. This article is just a write up showing how one would build on their existing approach to tackle the flicker problem. My portfolio is built on Next.js, but I think a similar approach can be used for other server side frameworks like Gatsby.

This article assumes that the reader has basic knowledge of React Context and Next.js. I have tried to link to the docs wherever possible.

Table of Contents

  1. Theme switcher using local storage and context
  2. The flicker problem
  3. Using Lazy State Initialisation
  4. Using cookies
  5. Customising the Document file
  6. Summary

Theme switcher using local storage and context

First things first. Here is a basic outline of the initial approach.

The theme is powered by React Context. The user preference is saved in localStorage. The changes are made using CSS variables.

Here is what context looks like:

const Context = createContext({
  theme: "",
  toggleTheme: null
});
Enter fullscreen mode Exit fullscreen mode

An object containing theme value and a method to modify it. Now any component that consumes this context can read the theme value (and modify it, if need be).

The CSS variables are stored in a constants file.

export const colorPalette = {
  dark: {
    background: "#222629",
    paraText: "#fff",
    headerText: "#fff",
    base: "#fff",
    pressed: "#c5c6c8",
    shade: "#2d3235"
  },
  light: {
    background: "#fffff",
    paraText: "#15202b",
    headerText: "#212121",
    base: "#212121",
    pressed: "#22303c",
    shade: "#f5f5f5"
  }
};

export const filter = {
  dark: {
    socialMediaIcon:
      "invert(100) sepia(0) saturate(1) hue-rotate(0deg) brightness(100)"
  },
  light: {
    socialMediaIcon: "invert(0) sepia(0) saturate(0) brightness(0)"
  }
};
Enter fullscreen mode Exit fullscreen mode

The colorPalette is self explanatory. The filter variable is where filters are stored.

Why filter for images? 🏞

It is very likely, that one would want to show logos/images in a different colour for different themes. A trick to do that is by using CSS filters which can change the logo colors🎨. (My website is monotone so it was much easier to convert the icons to black and white). This way the page does not have to make request to a new image. On noticing the above GIF, one can see green logos (their original colour) initially, which turn black and white.

Below is the function that changes the colour palette and the filters based on the input theme:

const changeColorsTo = (theme) => {

  const properties = [
    "background",
    "paraText",
    "headerText",
    "base",
    "pressed",
    "shade"
  ];

  if (typeof document !== "undefined") {
    properties.forEach((x) => {      document.documentElement.style.setProperty(
        `--${x}`,
        colorPalette[(theme === undefined ? "LIGHT" : theme).toLowerCase()][x]
      );
    });
    document.documentElement.style.setProperty(
      `--socialIconsfilter`,
      filter[(theme === undefined ? "LIGHT" : theme).toLowerCase()]
        .socialMediaIcon
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

setProperty is used to set the CSS variables.

Below is the ContextProvider, which wraps all elements on the webpage.

const ContextProvider = (props) => {

  let [currentTheme, setTheme] = useState("LIGHT");

  useEffect(() => {
    let storageTheme = localStorage.getItem("themeSwitch");
    let currentTheme = storageTheme ? storageTheme : "LIGHT";
    setTheme(currentTheme);
    changeColorsTo(currentTheme);
  }, []);

  let themeSwitchHandler = () => {
    const newTheme = currentTheme === "DARK" ? "LIGHT" : "DARK";
    setTheme(newTheme);
    window && localStorage.setItem("themeSwitch", newTheme);
    changeColorsTo(newTheme);
  };

  return (
    <Context.Provider
      value={{
        theme: currentTheme,
        toggleTheme: themeSwitchHandler
      }}
    >
      {props.children}
    </Context.Provider>
  );
};

export { Context, ContextProvider };
Enter fullscreen mode Exit fullscreen mode

The currentTheme is initialised with LIGHT. After the first mount, the correct theme value is read from localStorage and updated accordingly. If localStorage is empty, then LIGHT is used.
The themeSwitchHandler function is called to change the theme. It performs three actions:

  1. Updates the CSS variables by calling changeColorsTo,
  2. updates the localStorage value, and
  3. sets the new value for currentTheme, so the context value is also updated.

The below is the code for _app.js. With Next.js, one can use a custom App component to keep state when navigating pages (among other things).

const MyApp = ({ Component, pageProps }) => {

  return (
    <>
      <Head>
        ....
        <title>Tushar Shahi</title>
      </Head>
      <ContextProvider>
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </ContextProvider>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The relevant part is how ContextProvider wraps all the components.

The flicker problem

The above code gives a hint as to why there is a flickering problem. Initially there is no information about the user preference. So LIGHT is used as the default theme, and once localStorage can be accessed, which is inside the useEffect callback (useEffect with any empty dependency array works like componentDidMount), the correct theme is used.

How to initialise the state correctly?

An update to the code could be done by utilising lazy initial state.

const setInitialState = () => {

  let currentTheme = "LIGHT";

  if (typeof window !== "undefined" && window.localStorage) {
    let storageTheme = localStorage.getItem("themeSwitch");
    currentTheme = storageTheme ? storageTheme : "LIGHT";
  }

  changeColorsTo(currentTheme);
  return currentTheme;
};

const ContextProvider = (props) => {
  let [currentTheme, setTheme] = useState(setInitialState);
.....
Enter fullscreen mode Exit fullscreen mode

setInitialState reads the theme value, changes the color and returns the theme. Because Next.js renders components on the server side first, localStorage cannot be accessed directly. The usual way to ensure such code runs only on client side is by checking for this condition:

typeof window !== "undefined"
Enter fullscreen mode Exit fullscreen mode

This does not help though. Again, there is a flicker. On top of that there is a hydration error.
Warning: Text content did not match. Server: "LIGHT" Client: "DARK" in ModeToggler component.

Hydration error in development environment

The issue: Server side value of theme is LIGHT and client side it is DARK. Understandable because localStorage is not available server side. This value is rendered as text in the ModeToggler component, hence the mismatch.

Using cookies 🍪

The network tab shows the value of theme in the HTML page being served is incorrect.

Network tab inspection

To fix this, a data store which is accessible to both client and server needs to be used. cookies is the way. And with Next.js data fetching methods it becomes easy to access them.

Implementing getServerSideProps on relevant pages does this:

export const getServerSideProps = async ({ req }) => {

  const theme = req.cookies.themeSwitch ?? "LIGHT";

  return {
    props: {
      theme
    } // will be passed to the page component as props
  };
};
Enter fullscreen mode Exit fullscreen mode

The above code runs on every request.

theme is made used in the MyApp component.

const MyApp = ({ Component, pageProps }) => {

      return(
      ....
      <ContextProvider theme={pageProps.theme}>
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </ContextProvider>
      ....
Enter fullscreen mode Exit fullscreen mode

Now, the prop theme is used to initialise the state in the ContextProvider.

const ContextProvider = ({ theme, children }) => {

  let [currentTheme, setTheme] = useState(() => {
    changeColorsTo(theme);
    return theme;
  });

  let themeSwitchHandler = () => {
    const newTheme = currentTheme === "DARK" ? "LIGHT" : "DARK";
    setTheme(newTheme);
    changeColorsTo(newTheme);
    if (document) document.cookie = `themeSwitch=${newTheme}`;
  };

  return (
    <Context.Provider
      value={{
        theme: currentTheme,
        toggleTheme: themeSwitchHandler
      }}
    >
      {children}
    </Context.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

The code using localStorage is replaced by the code using cookies. Now the information about correct theme is present on the server side too. Inspecting the network tab confirms that.

Correct theme value from server side

But there is still a flicker. 😩

Webpage loading in a throttled environment

The function changeColorsTo has a check for the existence of document so that the code manipulating the colors only runs on client side. The loaded html file shows that the styles are not loaded from the server side. This indicates that the client side code (not the server side code) updates all the CSS variables, even if the correct value of theme is available on the server side.

Elements Panel in Dev Tools
=

How to utilise the cookie info to add the styles on server side?

Customising the Document file

_document.js is used in Next.js to update the html and body tags. The file runs on the server side. It is a good place to load fonts and any scripts (both inline and remote).

Document component can implement a getIntialProps. This is also a data fetching method. It has access to context and request. This is where one can access the themeSwitch cookie and pass it on as a prop.

MyDocument.getInitialProps = async (ctx) => {

  const initialProps = await Document.getInitialProps(ctx);
  const theme = ctx.req?.cookies?.themeSwitch ?? "LIGHT";

  return { ...initialProps, theme };
};
Enter fullscreen mode Exit fullscreen mode

The Document component can read the theme and create the styles object. This will be added to the html tag. Now every time any page is served, the html styles will be filled directly by the server.

Why optional chaining to access cookies?

There is a need for the optional chaining operator because getInitialProps runs for every page served. And 404 pages do not have data fetching methods like getServerSideProps or getInitialProps. req object does not exist for 404.js and hence accessing cookies will throw an error.

const MyDocument = ({ theme }) => {

    const styleObject = useMemo(() => {
    let correctTheme =
      colorPalette[(theme === undefined ? "LIGHT" : theme).toLowerCase()];
    let correctFilter =
      filter[(theme === undefined ? "LIGHT" : theme).toLowerCase()];

    const styles = {};

 Object.entries(correctTheme).forEach(([key, value]) => {
      styles[`--${key}`] = value;
    });
    styles[`--socialIconsfilter`] = correctFilter.socialMediaIcon;
    return styles;
  }, [colorPalette, filter]);

  return (
    <Html lang="en" style={styleObject}>
      <Head>
      ....
      </Head>
      <body>
        <Main />
        <NextScript />
        .... 
      </body>
    </Html>
  );
};
Enter fullscreen mode Exit fullscreen mode

The component body creates a stylesObject using the correct theme with the colorPalette and filter object.

Webpage loading in a throttled environment

Yes. There is no flicker now. 🎉 The website is flicker-less.

The network tab shows that the CSS variables are being pre filled when the page is served.

Network tab inspection

With this set, the context code can be updated. Now it is not required to change colors on the first render. So there is no need to have a function in useState.

const ContextProvider = ({ theme, children }) => {
  let [currentTheme, setTheme] = useState(theme);
Enter fullscreen mode Exit fullscreen mode

Summary

  1. There is a need to use cookies instead of localStorage because information is needed both on client and server side.
  2. Theme can be read from cookies in data fetching methods and passed as props to all the pages.
  3. Updating the CSS variables using Context will still cause a flicker because the server rendered page is served with the wrong colors.
  4. To get the correct value in CSS variables Next.js's Document component is customised. It can update the body & the html and is run on the server side.

The code is deployed on vercel. One might notice that the 404 page does not get the correct theme, because of the implementation.

Hope this is helpful to people reading this.

Top comments (4)

Collapse
 
tusharshahi profile image
TusharShahi

This is my first blog post. I would love to receive feedback from you all about the post, the code, or even the portfolio website itself. Moreover, I would love to see how you handle the theme toggle switch on your projects.

Collapse
 
egolegegit profile image
egolegegit

Hello!
Great article!
But there is one problem with Incremental Static Regeneration.
Haven't found a solution yet. What the hell do you think about this?

Collapse
 
tusharshahi profile image
TusharShahi

Thanks.

True. Since pages (with the right theme) require user information to generate the page, they cannot be statically generated.

One approach I had in mind was to create multiple versions of your pages (dark and light) and return them to the user. They will all be statically generated. I think these pages will benefit from ISR. I haven't tried it though.

Thread Thread
 
egolegegit profile image
egolegegit

Thank you!
I would be grateful if you find and share the solution to this problem :)