Flickerless dark mode in 11ty with Tailwind CSS

tengkufarhan

Tengku Farhan

Posted on September 5, 2023

Flickerless dark mode in 11ty with Tailwind CSS

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 :

mkdir project-name && cd project-name
Enter fullscreen mode Exit fullscreen mode

Once in the folder init a node project & install the 11ty package as dev dependency :

npm init -y
npm install -D @11ty/eleventy
Enter fullscreen mode Exit fullscreen mode

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 :

npm install -D tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

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 :

module.exports = function(eleventyConfig) {
  // Return your Object options:
  return {
    dir: {
      input: "src",
      output: "_dist"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode
  • 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 :

"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"
  }
Enter fullscreen mode Exit fullscreen mode

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 :

npm install -D npm-run-all
Enter fullscreen mode Exit fullscreen mode

and now you can execute both of these scripts :

"start": "npm-run-all --parallel dev:*",
"build": "run-s build:*",
Enter fullscreen mode Exit fullscreen mode

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 :

<!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>
Enter fullscreen mode Exit fullscreen mode

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 :

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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 :

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{njk,md}', './src/**/*.svg'],
  darkMode: 'class',
  theme: {},
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode
  • 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 :

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');
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Then, add this code just right before the </body> tag in index.njk :

<script type="module" src="/js/bundle.js"></script>
Enter fullscreen mode Exit fullscreen mode

Now, return to index.njk file in src, and write a button like this :

<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>
Enter fullscreen mode Exit fullscreen mode

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 :

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',
     },
  };
};
Enter fullscreen mode Exit fullscreen mode

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.
<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>
Enter fullscreen mode Exit fullscreen mode

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.
💖 💪 🙅 🚩
tengkufarhan
Tengku Farhan

Posted on September 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related