How I made my dark mode

update: My website has changed but the method is the same. I have edited code snippets for more generic examples.

If you click the sun emoji at the top of this website, you should see the theme switch from light to dark. Dark themes are supposed to be more comfortable to read at night or in the dark.

I did this with the least possible amount of JS involved. The script that I wrote is at the end of this post. I wanted to thoroughly explain the approach too.

About Cascading Style Sheets (CSS)

You can style a naked website with one or more CSS files linked in the <head> section of your HTML. CSS are rules for the browser to customize your website elements in the way that you want them to appear. They’re like make-up instructions. A website can look and feel drastically different with different CSS rules.

/* variables */
$colorBackground: white;
$colorAccent: blue;
$fontMonospace: 'ui-monospace', 'Menlo', 'Roboto Mono', 'Consolas', monospace;

/* elements */
#example-div {
    background-color: darken($colorBackground, 5%);
    border: 1px solid $colorAccent;
    box-shadow: 0 10px 25px 0 rgba(0, 0, 0, .25); 
    font-family: $fontMonospace;
    font-size: small;
    margin: 30px auto;
    text-align: center;
}

HTML and CSS serve a different purpose so it’s good practice to separate your markup from those cosmetic rules by linking to an external CSS—as opposed to manually customizing every single instance of your reoccuring elements inline, across the whole website, which would be insane.

While my final CSS isn’t huge, I do have a few different Sass files on my end for, once again, separation of concerns. Sass is a CSS preprocessor that makes styling more efficient with new rules, operators, nesting and ways to create functions. Sass files have the .sass or .scss extension. Writing regular CSS syntax is still perfectly valid in Sass.

Before I create a dark theme, my style sheets are:

  • reset.css (or normalize.css)

Picked off the Internet. It is used to clear the browser’s default formatting of HTML elements, removing potential inconsistencies between different browsers. It’s a custom blank slate that will drive the same basic appearance for everyone.

  • main.scss

This is my main style sheet where I can now edit everything from element colors to sizes, hover effects, transitions, etc. If it becomes too long and confusing to work with, it’s a good idea to split it into more focused files.

  • grid.scss

For example I have a dedicated CSS file to keep grids, tables or image galleries responsive. The extra convenience of keeping it separate from the rest is that when I make another website, I can easily use this file again as is. It’s like a little handy standalone module.

  • global.scss

An import file of all of the above.

Setting up the default theme within my HTML

In my <head> partial, I tell my website generator to fetch global.scss and to convert that file from Sass to CSS for browsers to be able to process it:

{{ $css := resources.Get "css/global.scss" | toCSS | minify | fingerprint "md5" }}
<link rel="stylesheet" href="{{ $css.Permalink }}">

I perform minification for the sake of it. It really does nothing here in terms of bandwidth saved but it helps me achieve a better score on Google’s PageSpeed Insights so I’m happy to oblige… I also give it a new unique filename everytime it changes for cache busting but Google doesn’t know that.

The result in the live website is something like:

<link rel="stylesheet" href="/css/global.min.9cdfb439c7876e703e307864c9167a15.css">

This is my one and single theme for now but it’s too bright, ah, my eyes.

Writing the dark theme with CSS

I create dark-theme.scss and, given its purpose, only copy the rules from main.scss with colors that will be rendered differently. In my previous example, the only relevant rules are the ones below so I copy them to dark-theme.scss and tweak them to my liking:

$colorBackground: black; /* was white */
$colorAccent: orange; /* was blue */

#example-div {
    background-color: lighten($colorBackground, 10%);
    border: 1px solid $colorAccent;
    box-shadow: none; /* as it would be invisible on black */
}

The omitted properties will be inherited. Indeed, dark-theme.css won’t be a replacement for global.css, more like an overlay. This will be easier and better for compatibility reasons.

Because I’m switching to a black background, I might have to edit a few specific rules related to transparency, shadows and whatnot.

Optionally, to smoothen the transition between light and dark, I create transitions.scss and add it to the list of my imported files in global.scss. It looks like this:

$duration: 1s;

body, pre /* etc */
{ transition: background-color $duration, color $duration; }

a, p /* etc */
{ transition: color $duration; }

Setting up the dark theme within my HTML

I generate a link for dark-theme.css like I did before but I replace the regular href attribute with a custom data-dark-theme attribute. The browser doesn’t know what this is about but it’s OK, it’s for the script.

Now my <head> includes one CSS file that is normally loaded—the complete default theme—and one that is prepped to be loaded but is not—the dark theme overlay:

<link rel="stylesheet" href="/css/global.min.9cdfb439c7876e703e307864c9167a15.css">
<link rel="stylesheet" data-dark-theme="/css/dark-theme.min.4cfdc2e157eefe6facb983b1d557b3a1.css">

I can already add a button in my menu since I know that I will need a trigger:

<button onclick="themeSwitch()">☀️</button>

And a link to my future script. This one will work best at the bottom of the <head> section:

    ...
    <script src="/js/theme-switch.js"></script>
</head>

The themeSwitch() function in theme-switch.js isn’t written yet but for now, I have nothing else to add in my HTML markup.

Loading and saving dark theme preferences with JS

The gist of loading the theme is that, on trigger, the script targets my custom <link> to copy its data-dark-theme value and it pastes that into a new href attribute, which turns that link into a valid set of rules that the browser can now fetch and apply to the current HTML elements. If the user clicks the button again while the href exists, the script deletes it (the href value) and the dark overlay disappears.

The problem is that programs don’t remember things they’re not being told to remember, so the user would have to do it every single time on every single page. Being constantly blinded by a default bright theme is not super convenient as far dark modes go, so I need a way to save its current state.

JavaScript has a localStorage property on which I can act upon. It allows me to store and retrieve data in the form of key-value pairs inside the browser Local Storage. This data is only available for the domain who set it up.

Even big websites whose data could be stored in their own servers and tied to a user account tend to use the Local Storage for non-valuable information. It wouldn’t matter if it was purged for some reason by the browser. It would just revert to its initial value and that’s fine. For example, YouTube uses this storage to remember some audio and video settings. I made a .gif where you can see volume value change as I play with the slider:

YouTube Volume Local Storage

The next time that I come back to YouTube with the same browser, the volume level will be where I left it off.

I’m going to use the same method.

I enable my dark mode same way as before but now I set a key-value pair lxp86-theme: "dark" at the same time in the Local Storage. If the user clicks the button again, "dark" will be replaced by "default". And if the user already has the "dark" value set in the Local Storage when accessing a page, then they won’t have to press the button at all as the dark theme CSS will be loaded automatically.

Here’s my complete script:

const darkThemeLink = document.querySelector("[data-dark-theme]");
const darkThemeData = darkThemeLink.getAttribute("data-dark-theme");

function themeSwitch() {
    if (localStorage.getItem('lxp86-theme') === 'dark') {
        darkThemeLink.setAttribute("href", "");
        localStorage.setItem('lxp86-theme', 'default');
    } else {
        darkThemeLink.setAttribute("href", darkThemeData);
        localStorage.setItem('lxp86-theme', 'dark');
    }
}

(function loadTheme() {
     if (localStorage.getItem('lxp86-theme') === 'dark') {
         darkThemeLink.setAttribute("href", darkThemeData);
     }
})();

It’s super small! It doesn’t have to be long, it works well enough.

And that’s how I made a dark mode from scratch on my static website!

[back]