Flicker Free Dark Mode in Vite SPA
Published: 2021-10-07 07:01:41

On some sites that implement automatic dark mode based on system preference, the change of colors is not immediate and result to flicker. I'll show you how to prevent that so your site visitor does not have to see the light mode if their system setting says so.

For the purpose of showcasing, I'll use React with [Vite](https://vitejs.dev/) with vanilla CSS. But, you can use this technique with some styling frameworks like Tailwind CSS and other UI framework too.

First, bootstrap your project directory with Vite `create-app`. If you're using other package manager such as `yarn` or `pnpm`, follow the guide in Vite [documentation](https://vitejs.dev/guide/).
```bash
npm init vite
```
Then choose `react` as the framework.

Our focus is to implement automatic dark mode, so we will only modify `index.css`, `App.css`, and `index.html`.

Before that, I'll explain the cause of flicker and how the solution works. The flicker happens when color styles for dark mode is not injected early enough when the site is loaded. Or, the style changes are too much so it takes some time for it to finish. 

So, the solution is to inject the theme specific styles as early as possible and make the stylesheet changes as little as possible by using CSS variables.

To inject the theme specific styles as early as possible, we're going to write an inlined IIFE script that will set theming class on `html` tag on page load. `index.css` file contains global styles and we will add CSS variables here for both `.light` and `.dark` class respectively.

The CSS variables names need to be identical for both classes. Therefore, local CSS file need not to change various color properties to adapt with `<html>` theme class changes.

Let's add the CSS variables:
```css
.light {
  --page-bg: hsl(300 20% 99%);
  --lo-contrast: hsl(300 20% 99%);
  --hi-contrast: hsl(252 4% 44.8%);
}

.dark {
  --page-bg: hsl(246 6% 9%);
  --lo-contrast: hsl(253 4% 63.7%);
  --hi-contrast: hsl(256 6% 93.2%);
}

/* 
 * Duplicate body selector for the sake of clarity what style is being added.
 * Feel free to combine it with the body selector below.
 *
 * Set body background and default text color here to make sure that 
 * at least background and text color, as the major colors, are  
 * immediately set based on the theme.
 */
body {
  background: var(--page-bg);
  color: var(--hi--contrast);
}

/* CSS from Vite React template. */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
    "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
    monospace;
}
```
In case you are curious, the colors that I used above is some of Mauve palette taken from [@radix-ui/colors](https://www.radix-ui.com/colors). It's awesome by the way!

Then, let's add the script on the `head` tag to set the class based on your system preference or user preference saved in `localStorage`:
```html
<script>
  (() => {
    const classList = document.documentElement.classList;
    const style = document.documentElement.style;
    const dark = window.matchMedia("(prefers-color-scheme: dark)");

    const update = () => {
      if (
        localStorage.theme === "dark" ||
        (!localStorage.theme && dark.matches)
      ) {

        classList.add("dark");
        style.colorScheme = "dark";
      } else {
        classList.remove("dark");
        style.colorScheme = "light";
      }
    };
    update();

    if (dark instanceof EventTarget) {
      dark.addEventListener("change", () => {
        delete localStorage.theme;
        update();
      });
    } else {
      dark.addListener(() => {
        delete localStorage.theme;
        update();
      });
    }
    window.addEventListener("storage", update);
  })();
</script>
```

Now, your `index.html` `head` tag will look like this:
```html
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <script>
    (() => {
      const classList = document.documentElement.classList;
      const style = document.documentElement.style;
      // Returns `MediaQueryList` instance. It will have `matches` 
      // property if `prefers-color-scheme` is set to 'dark' 
      const dark = window.matchMedia("(prefers-color-scheme: dark)");

      const update = () => {
        if (
          localStorage.theme === "dark" ||
          (!localStorage.theme && dark.matches)
        ) {
          classList.add("dark");
          classList.remove("light");
          style.colorScheme = "dark";
        } else {
          classList.add("light");
          classList.remove("dark");
          style.colorScheme = "light";
        }
      };
      update();

      // Re-applied theme class if `prefers-color-scheme` media query is changing.
      // Modern browser `MediaQueryList` extends `EventTarget`
      if (dark instanceof EventTarget) {
        dark.addEventListener("change", () => {
          update();
        });
      } else /* Fallback for old browser */  {
        dark.addListener(() => {
          update();
        });
      }
      // Re-applied theme class if `localStorage` is being updated.
      window.addEventListener("storage", update);
    })();
  </script>
  <title>Vite App</title>
</head>
```

The script being placed right after `charset` and `viewport` meta tag to make sure that the theme evaluation is being executed first before anything else.

Aaaand, congratulation! Now, your Vite site dark mode is flicker free!

Next, we will add theme switcher button on top of this. But, that's for the next article or the next iteration of this article. You can also add it yourself! You simply need to create a button that update `localStorage.theme` to 'dark' or 'light'.

Please let me know if you find mistakes or better solutions. Thanks for reading!

Source code: [https://github.com/izznatsir/vite-auto-theme](https://github.com/izznatsir/vite-auto-theme).