Flickerless dark mode in 11ty with Tailwind CSS
Tengku Farhan
Posted on September 5, 2023
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
Once in the folder init a node project & install the 11ty package as dev dependency :
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 :
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 :
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 definesrc
it will be looking for a folder namedsrc
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"
}
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
and now you can execute both of these scripts :
"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 :
<!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
:
@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
:
/** @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 typeclass
andmedia
. We're usingclass
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');
}
}
});
Then, add this code just right before the </body>
tag in index.njk
:
<script type="module" src="/js/bundle.js"></script>
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>
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',
},
};
};
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>
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.
Posted on September 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.