Flickerless dark mode in 11ty with Tailwind CSS
Tags: webdev, javascript, tutorial, html
Published: 2023-09-05 02:45:39

Just a couple of days ago, I was about to rebuild my portfolio website from scratch. As a typical web developer, I stumbled upon the question of which framework I should use for my static two-pages website. I thought going for a full-stack framework would be overkill (e.g., Next.js, Gatsby, etc.), so my choice was limited to some static web generators. I settled on 11ty since I'm not familiar with Ruby and Go-based SSGs like Jekyll and Hugo. 

But the problem doesn't stop there because later on, I was faced with an annoying bug when trying to implement the dark mode. So, every time I try to reload the page in dark mode, it always flickers. I thought the cause was a statically rendered page and client-side dark mode implementation, but it turns out it was something way simpler than that :)

Okay, that's the end of the story. Now, let's get into the tutorial. 🚀

> Don't worry, this tutorial is supposed to be applicable to any SSG (static site generators) generated website, regular static HTML, or even a server-rendered one

## Installation
Create a folder with the name of your project :

```bash
mkdir project-name && cd project-name
```
Once in the folder init a node project & install the 11ty package as dev dependency :
```bash
npm init -y
npm install -D @11ty/eleventy
```

> Essentially, 11ty is a CLI tool, so you can install it globally and use it everywhere. However, for the sake of good encapsulation practice, I prefer to install it only in the project that will utilize it

Add tailwind package as dev dependencies & init to generate `tailwind.config.js` & `postcss.config.js` file :
```bash
npm install -D tailwindcss postcss autoprefixer
```
After all necessary packages are installed, now it's time to setup ⚙
## Setup

Create `.eleventy.js` config file in the folder root and copy this snippet into the file :
```js
module.exports = function(eleventyConfig) {
  // Return your Object options:
  return {
    dir: {
      input: "src",
      output: "_dist"
    }
  }
};
```
- `input` is the entrypoint for the 11ty to generate html from template, by default it's equal to project folder root. So if you define `src` it will be looking for a folder named `src` and process all the template inside
- `output` is the folder where the final processed template will be written to

Update the `package.json` scripts to look like this :

```json
"scripts": {
    "start": "npm-run-all --parallel dev:*",
    "build": "run-s build:*",
    "dev:11ty": "eleventy --serve",
    "dev:css": "tailwindcss -i src/assets/css/main.css -o _dist/assets/css/main.css --watch --postcss",
    "build:11ty": "eleventy",
    "build:css": "tailwindcss -i src/assets/css/main.css -o _dist/assets/css/main.css --minify --postcss"
  }
```
Since npm doesn't have a parallel script execution by default we need to install extra package for it called `npm-run-all`

Install `npm-run-all` as dev dependency :

```bash
npm install -D npm-run-all
```

and now you can execute both of these scripts :

```json
"start": "npm-run-all --parallel dev:*",
"build": "run-s build:*",
```
What about the `run-s`? Don't worry, it comes from `npm-run-all` by default, and what it does is execute the script sequentially instead of in parallel, which we don't need to do for building the production version.

After all the necessary scripts and config are set up, now we're going to write the simple dark mode logic.

First, create an `src` folder and make `index.njk` template file inside, copy this basic snippet into the file : 

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <script>
      if (
        localStorage.getItem('color-theme') === 'dark' ||
        (!('color-theme' in localStorage) &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      ) {
        document.documentElement.classList.add('dark');
      } else {
        document.documentElement.classList.remove('dark');
      }
    </script>

    <link rel="stylesheet" href={{
      '/assets/css/main.css' | url
    }}
    type="text/css" />

    <title>Dark Mode in 11ty</title>
  </head>
  <body class="bg-white dark:bg-knight">
    <div>
      hello from index
    </div>
  </body>
</html>
```
Notice how there's a stylesheet import in the head but we don't actually have the css file, now time to create one inside `src/assets/css` named `main.css` (you can put the css file anywhere you like but for convenience I prefer to put it on a categorized folder such as `assets/css`)

Now copy this snippet into `main.css` :

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Also don't forget to setup the tailwind config so that the tailwind compiler can search through all used class in template and transform it into regular CSS, now copy this snippet into `tailwind.config.js` :

```js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{njk,md}', './src/**/*.svg'],
  darkMode: 'class',
  theme: {},
  plugins: [],
};
```
- `content` is a collection of path for tailwind compiler to look up
- `darkMode` is a dark-mode methods for tailwind, it has two type `class` and `media`. We're using `class` here because is the most flexible one

Okay now create a file named `bundle.js` or whatever you like inside `src/js` folder, and copy this snippet : 

```js
const THEME_KEY = 'color-theme';
const toggleButton = document.querySelector('#dark-mode-toggle');
const docEl = document.documentElement;

toggleButton.addEventListener('click', function () {
  if (localStorage.getItem(THEME_KEY)) {
    if (localStorage.getItem(THEME_KEY) === 'light') {
      docEl.classList.add('dark');
      localStorage.setItem(THEME_KEY, 'dark');
    } else {
      docEl.classList.remove('dark');
      localStorage.setItem(THEME_KEY, 'light');
    }

    // if NOT set via local storage previously
  } else {
    if (docEl.classList.contains('dark')) {
      docEl.classList.remove('dark');
      localStorage.setItem(THEME_KEY, 'light');
    } else {
      docEl.classList.add('dark');
      localStorage.setItem(THEME_KEY, 'dark');
    }
  }
});
```
Then, add this code just right before the `</body>` tag in `index.njk` : 

```html
<script type="module" src="/js/bundle.js"></script>
```
Now, return to `index.njk` file in `src`, and write a button like this : 

```html
<body class="bg-white dark:bg-knight">
  <div>
    Hello from index
      <button id="dark-mode-toggle" class="-mt-1" aria-label="dark-mode-button">
       <p class="dark:hidden">Dark</p>
       <p class="hidden dark:inline">Light</p>
      </button>
  </div>
  <script type="module" src="/js/bundle.js"></script>
</body>
```
Make sure the button id is equal to the one in `const toggleButton = document.querySelector()`, in this case is `dark-mode-toggle` 

Some people would like to switch the dark/light icon or text inside the toggle button directly from JavaScript, but my approach here is somewhat unconventional yet highly effective and simple, using the `dark:` pseudo-class itself.

Lastly, don't forget to passthroughCopy all the unprocessed files to the _dist folder, since 11ty only processes template files such as Nunjucks (.njk), Liquid (.liquid), etc.

Write this snippet into `.eleventy.js` :

```js
module.exports = function (eleventyConfig) {

// we're copying the bundle.js to _dist/js since is not proccessed
 eleventyConfig.addPassthroughCopy({ 'src/js/bundle.js': 'js/bundle.js' })

/*
* but we don't need to copy the CSS because it has already been 
* processed by the Tailwind, you can see the scripts in package.json
* and how they instruct the compiler to output it into the 
* _dist/assets/css folder.
*/

// you can copy the rest of unprocessed file here with eleventyConfig.addPassthroughCopy()

  return {
     dir: {
       input: 'src',
       output: '_dist',
     },
  };
};
```

After you have completed all the steps above, run the `npm run start` command in your project directory, go to `localhost:8080`, test the toggle button, and voilà, you should now have a flicker-free dark mode on your static site using Tailwind CSS and vanilla JavaScript.

## Summary
- What is the solution for this? Why is dark mode not flickering anymore? Well, the reason is pretty simple. This inline script inside the `<head>` is render-blocking and runs immediately after request and since there's no 'async' or 'defer' keyword. Everything starting from downloading and executing this script will be blocking the HTML render, and therefore, there's no FOUC (flash of unstyled content) happening.
<br>
```html
<script>
      if (
        localStorage.getItem('color-theme') === 'dark' ||
        (!('color-theme' in localStorage) &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      ) {
        document.documentElement.classList.add('dark');
      } else {
        document.documentElement.classList.remove('dark');
      }
</script>
```
You might want to place this script above any other render-blocking assets, such as CSS stylesheet links and third-party CDN fonts.
- You can read the complete 11ty documentation here : https://www.11ty.dev/docs/
- Checkout the implementation here : https://kebabhan.netlify.app (try to reload the page in dark mode to see how it's not flickering)
- That's it for today. I hope you liked the tutorial, and if there's something you want to correct or add, please comment down below.