How I made my dark mode

If you click the light bulb emoji at the top right corner 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.

The actual script needed to enable this is at the bottom of this post but I wanted to thoroughly explain my use case. This is like a long tutorial for people who’d like to achieve something similar by themselves.

About Cascading Style Sheets

You can style a naked website with one or more CSS files that are 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.

#nav {
    background: lighten($colorBackground, 10%); // make it pop
    font: $fontMonospace;
    padding: 1.25ch 1ch;
    position: fixed;
    width: 100%;
    z-index: 1;
}

A website can look and feel drastically different with different CSS rules but its functionalities won’t change. That’s because CSS rules are purely meant for display and a website should work without them. 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 customize 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. Regular CSS syntax is still valid in Sass so the transition for front-end developers is pretty smooth.

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

reset.scss

A lot of websites use a CSS reset picked off the Internet. It forces a bunch of values to 0 or none right from the start. Because different browsers have slightly different default settings for different HTML elements, it would be annoying to deal with those differences in a per-element basis. With this workaround, you have a clean slate to assure a more consistent experience across all browsers.

fonts.scss

I self-host my Google fonts to avoid Google serving its cookies when used as a third-party. I avoid connections to other servers in general. This file contains all the @font-face rules needed in this case.

main.scss

This is my main style sheet where I edit everything from element colors to sizes, grid layouts, transitions and more. If I had a much more complicated website, it would be a good idea to split it into more focused files.

fontresize.scss

This is a way for me to keep font size responsive once and for all. It makes heavy use of Sass functions, thus looks more like a programming script than your regular style sheet. I give it a min/max font size and a mix/max screen size and the CSS will interpolate everything nicely for paragraphs and headings.

global.scss

An import file of all of the above.

In my <head> template, 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 unique filename for cache busting (I’m still making a lot of changes every day) 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 only copy the rules from main.scss that will end up being different. Given the purpose, they’re almost all color-related. For example in my previous #nav element, the only relevant rule of the whole block of CSS is the one below, so I copy it to dark-theme.scss and I omit the other properties:

#nav {
    background: lighten($colorBackground, 10%);
}

Everything else is being 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.

Thanks to the use of variables, I don’t even have to modify each one of those unique dark theme rules manually. All I have to do is edit their original values after I copied them from main.scss to dark-theme.scss:

$colorBackground: hsl(0, 0%, 0%);     // now the baseline for background color is pure black

Because I’m switching to a darker background, I still have to edit a few more specific rules related to transparency, shadows and whatnot. Other than that, I’m done with the CSS!

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.

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 to my navigation bar 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 we’re back to the default theme.

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 one of the values change as I play with the volume:

YouTube Volume Local Storage

The next time that I come back to YouTube with this 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 he 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 defaultTheme() {
     if (localStorage.getItem('lxp86-theme') === 'dark') {
         darkThemeLink.setAttribute("href", darkThemeData);
     }
})();

It’s super short! 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]