Usually, there is nothing better than a crisp, clean, white website. But at night when you are in a dark room, it's not so nice for your eyes.
And yes, you shouldn’t be on your phone at night. That glare is bad for you. And it can cause problems with your sleep.
But we all do it. Don’t we?!
Dark Mode to the rescue!
You've probably used Dark Mode on various apps. It's been a system-wide feature on the iPhone since iOS 13.
After visiting a few blogs with a dark mode button as a way to toggle between light and dark, I thought it was pretty cool. So, like any decent human on the internet, I stole the idea for my blog builder.
The following features were essential:
- client-side code (as I run a static site)
- would identify/respect OS dark mode preference
- the user could toggle dark mode
- dark mode settings would persist between pages
I thought it would take a whole bunch of JavaScript. But that's something to keep minimal on a static site. I love JavaScript, but search engines don't love it so much. And on something like a blog, the quicker it loads the better. For users and SEO.
The Code
Luckily it only took 18 lines of JavaScript. And that isn’t just lucky. It's essential that the script is as small as possible for performance because it needs to be a blocking resource (more on that later).
function setDarkMode(dark, preference) {
if (dark) {
preference !== "dark" ? localStorage.setItem('theme', 'dark') : localStorage.removeItem('theme');
document.documentElement.classList.add('dark');
} else if (!dark) {
preference !== "light" ? localStorage.setItem('theme', 'light') : localStorage.removeItem('theme');
document.documentElement.classList.remove('dark');
}
};
const preference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (localStorage.getItem('theme') === "dark" || (!('theme' in localStorage) && preference === 'dark')) {
setDarkMode(true, preference);
}
window.onload = function () {
document.getElementById('button-dark-mode').addEventListener('click', function() {
setDarkMode(!document.documentElement.classList.contains('dark'), preference);
});
};
Now let's break the code down with comments to give some details on each stage.
First of all, there's the setDarkMode
function. The function takes two arguments dark
and preference
.
// sets dark mode
function setDarkMode(dark, preference) {
if (dark) { // set dark mode
// set as the theme if user overriding OS preference (remove theme setting if respecting OS preference)
preference !== "dark" ? localStorage.setItem('theme', 'dark') : localStorage.removeItem('theme');
// change to dark mode by adding class to <html> element
document.documentElement.classList.add('dark');
} else if (!dark) { // remove dark mode
// set as the theme if user overriding OS preference (remove theme setting if respecting OS preference)
preference !== "light" ? localStorage.setItem('theme', 'light') : localStorage.removeItem('theme');
// change to light mode by removing class from <html> element
document.documentElement.classList.remove('dark');
}
};
If dark
is true, the function will add the dark
class to our html element on the page. As in it will look like this <html lang="en" class="dark">
. That's what the document.documentElement.classList.add('dark');
line does.
But you will also want Dark Mode to persist when you navigate to another page. And that's what the line preference !== "dark" ? localStorage.setItem('theme', 'dark') : localStorage.removeItem('theme');
does. If the user's preference isn't dark, but they set Dark Mode, we will store that in local storage. So we know to give them darkness next time they load a page.
The other thing the setDarkMode
function does, is remove Dark Mode if the user clicks the button again. This else if (!dark)
section does the opposite of what we do if dark is true.
So now that the function to set dark mode is written, we need to use it. And that's what the next portion of the code does.
// check if we need to add dark class from theme or OS preference
const preference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (localStorage.getItem('theme') === "dark" || (!('theme' in localStorage) && preference === 'dark')) {
setDarkMode(true, preference);
}
One of the requirements of the code was that it should respect the OS preference. If my device is set to dark mode and I land on my blog, it should be dark. I don't want to turn on dark mode manually.
So first, we can check what the preference is (if there is one).
const preference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
And if the user has turned on dark mode, either in the site settings or as an operating system preference, we call the setDarkMode
function, with true
for the dark
argument.
if (localStorage.getItem('theme') === "dark" || (!('theme' in localStorage) && preference === 'dark')) {
setDarkMode(true, preference);
}
The reason why I also pass the preference argument to the setDarkMode
function is to see if the site setting for Dark Mode matches the preference. If it does, I don't save it in local storage. If I did, and the OS preference changed (e.g. on the iPhone you can have it automatically switch to match daylight), my site wouldn't update. It would stay dark until it was manually switched again by the user.
Avoiding a white flash
You may have noticed that everything we have done so far is before the document loads. Adding the event listener to the button is the only part of the code that waits for the document to load. And there's a reason why this is important.
At first, I waited for the document to load before I checked the preference and set dark mode. I did this because I would change which SVG image displayed with JavaScript, and I needed the document to load to do that. And that mostly worked fine on a fast connection.
But I loaded a video on one of my test posts, and I noticed a white flash of content before my Dark Mode setting would kick in. Not cool.
To avoid the white flash, I need to set Dark Mode before the document loads. That means no waiting for buttons to load, so I moved the image selection (deciding which SVG should get hidden) to CSS. Which looks like this...
/* show correct dark mode icon */
html.dark #button-dark-mode #light-mode-icon {
@apply hidden;
}
html:not(.dark) #button-dark-mode #dark-mode-icon {
@apply hidden;
}
Now the only part of the JavaScript that needs to wait for the document to load is the event listener. And that's fine because no one can click that button until the document loads anyway!
// on load add the event listener to the button
window.onload = function () {
document.getElementById('button-dark-mode').addEventListener('click', function() {
setDarkMode(!document.documentElement.classList.contains('dark'), preference);
});
};
A quick note here. This JavaScript code alone won't give you a beautiful looking Dark Mode website. It just adds (and removes) a dark
class on your <html>
element! You'll need to add styles to your CSS for how you want your site to look once the dark class is triggered. I use TailwindCSS, which includes a dark:
variant for quick and easy styling.
Performance
I'm including this script in the <head>
section of my static site. And it's a blocking script. So how does that hit performance?
<script src="/js/darkmode.min.js"></script>
I minimised the JavaScript file first with uglifyjs.
The lighthouse scores are in and they are... pretty good! I'm loading some other resources, like Google Fonts that knocked me down to a 99 but I'm happy with the result.
Green, green, green, green. Living the dream!
You can check out Dark Mode on this very blog, which uses the script talked about in this post. And you can get the static blog template I built on GitHub, which includes all the code for Dark Mode (including JavaScript and CSS).