A Simple Guide to Dark Mode with Gatsby.js

Everything you need to know to setup Dark Mode for your site.

I recently finally implemented Dark Mode for this blog (try that toggle in the top right), so it seemed appropriate to publish a short walkthrough for anyone else wanting to do the same thing. This post will use a Gatsby.js site as an example, but the concepts here apply to really any server-side rendered site (e.g. using Next.js, too).

Let’s get started!

Note: this post assumes working React.js knowledge and basic understanding of web development in general.

1. Picking the Initial Theme

The first question we have to answer is: when a user starts loading your site, what theme (light or dark) will you show them initially? Here’s how we’ll do it:

  1. Do we have a saved theme preference for this user from a previous visit? If so, use it.
  2. Does the user have an operating system preference for dark mode? If so, use it.
  3. Default to Light mode.

Saved Theme Preference

If the user previouly explicitly toggled into light / dark mode, we should save that setting for them and respect it on their next visit. To do this, we’ll use the localStorage API. Here’s what it might look like:

// Save this at some point
localStorage.setItem('preferred-theme', 'dark');

// User can close the tab, quit the browser, etc...

// Come back later and this will still be set
const theme = localStorage.getItem('preferred-theme');

Operating System Preference

There’s also a simple API for this: the prefers-color-scheme media query. Here’s an example:

const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

if (darkQuery.matches) {
  console.log('The user prefers dark mode!');
}

darkQuery.addListener(e => {
  console.log(`Preference update: ${e.matches ? 'does' : 'does not'} prefer dark mode`);
});

Putting it Together

One common pitfall we should avoid here is the dreaded dark mode flash, where the theme changes halfway through loading:

To prevent this, we just need the code that picks the theme to execute before our HTML page gets parsed and rendered by the browser. An easy way to do this using Gatsby is with Gatsby Server Rendering APIs, specifically the onRenderBody callback. We can use the setHeadComponents function passed to onRenderBody to inject our code:

gatsby-ssr.js
const React = require('react');

exports.onRenderBody = ({ setHeadComponents }) => {
  setHeadComponents([
    <script
      dangerouslySetInnerHTML={{
        __html: '// REPLACE THIS WITH ACTUAL CODE',
      }}
    />,
  ]);
};

Gatsby will insert this <script> tag into the <head> of our HTML, and it will be executed early enough to prevent any dark mode flash!

Now, you might be thinking: Woah. Isn’t this super sketchy? I mean, dangerouslySetInnerHTML, seriously? If that was you, your instincts are right - directly setting HTML from code should be avoided when possible because of the risk of XSS attacks. In this case, though, we’re the ones setting the HTML of the <script> tag, so it’s safe.

Further Reading: the documentation for dangerouslySetInnerHTML.

Here’s the code we’ll actually use in that <script> tag:

init-theme.js
// This is the code we're inserting at the top of our page

(function() {
  // Update the current theme to either 'light' or 'dark'
  function setTheme(theme) {
    window.__theme = theme;
    // TODO: do other logic to update theme here
    console.log('Theme updated:', theme);
  };

  // Save the user's explicit theme preference.
  // We're attaching this to window so we can access it anywhere.
  // We'll need it later in this post.
  window.__setPreferredTheme = function(theme) {
    setTheme(theme);
    try {
      localStorage.setItem('preferred-theme', theme);
    } catch (e) {}
  };

  // Is there a Saved Theme Preference in localStorage?
  let preferredTheme;
  try {
    preferredTheme = localStorage.getItem('preferred-theme');
  } catch (e) {}

  // Is there an Operating System Preference?
  let darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

  // PICK THE INITIAL THEME  // 1. Use the theme from localStorage, if any  // 2. Use the OS theme, if any  // 3. Default to light  setTheme(preferredTheme || (darkQuery.matches ? 'dark' : 'light'));})();

Note: the function() {} wrapper is an immediately-invoked function expression (IIFE), which we’re using to limit the scope of our new variables (prevent them from being global).

Inlining this code into the <script> tag in our gatsby-ssr.js file will make our initial theme selection work!

Now, let’s make setting the theme actually trigger visual changes…

2. Dark Mode CSS

Basically every Dark Mode implementation uses CSS variables, and we’re going to be no different. You’ll need to ensure all colors that should be flipped in Dark Mode must be CSS variables. Here’s an example of what your CSS might look like:

/* Light Mode */
:root {
  --color-background: white;
  --color-text: #222;
  --color-primary: #164BC5;
  /* ... more colors */
}

/* Dark Mode */
:root.dark {
  --color-background: black;
  --color-text: #DDD;
  --color-primary: #0F9640;
  /* ... more colors */
}

This can be an iterative process - no need to make all your colors dark mode compatible at once. Once you have some (or all) colors converted, we can update our theme logic from before to actually apply the .dark class when appropriate:

init-theme.js
// This is the code we're inserting at the top of our page

// ...

  // Update the current theme to either 'light' or 'dark'
  function setTheme(theme) {
    window.__theme = theme;
    console.log('Theme updated:', theme);

    if (theme === 'dark') {      document.documentElement.className = 'dark';    } else {      document.documentElement.className = '';    }  };

// ...

Now, if your OS preference is dark mode, you should see dark mode colors on your site!

3. Creating a Toggle

Our only task left is to build a UI component to give users the power to directly choose their theme. For the purposes of this post, we’ll use react-toggle to build a simple toggle button as a starting point, but you should customize the look and feel of your toggle to fit with your site.

Here’s our toggle component:

DarkModeToggle.js
import React, { useCallback, useState } from 'react';
import Toggle from 'react-toggle';

const DarkModeToggle = () => {
  if (typeof window === 'undefined') {
    // Never server-side render this, since we can't determine
    // the correct initial state until we get to the client.
    // Alternatively, use a loading placeholder here.
    return null;
  }

  const [checked, setChecked] = useState(window.__theme === 'dark');

  const onChange = useCallback(
    e => {
      const isChecked = e.target.checked;
      setChecked(isChecked);
      window.__setPreferredTheme(isChecked ? 'dark' : 'light');
    },
    [setChecked]
  );

  return <Toggle checked={checked} onChange={onChange} />;
};

export default DarkModeToggle;

Let’s break down what’s happening:

  1. Return nothing when this component is server-side rendered. We can’t determine whether to use light or dark mode until we actually reach the client browser, so we don’t know what state to show the toggle in. Using a toggle placeholder here instead is also fine.
  2. Check the current theme using window.__theme, and update the theme using window.__setPreferredTheme. We attached both of these to the window earlier.
  3. Remember that window.__setPreferredTheme also updates localStorage with the user’s preference. If the user uses our toggle, their preference will be saved for future visits back to the site!

Place your dark mode toggle wherever you want on your site and try it out!

4. Conclusion + Extensions

You’re done! We’ve built a dark mode solution that has no initial flash, is smart about respecting / remembering the user’s preferences, and is fully controllable by the user.

What we’ve got so far works well, but there’s always room to improve. I’ll include a few ideas below to get you started.

Minification

Minifying the JS code we inject into the head of our HTML can help improve the overall speed of your site. Here’s a (simple) start that trims whitespace:

<script
  dangerouslySetInnerHTML={{
    __html: `
  (function() {
    // code here
    // ...
  })();
`.replace(/\n/g, ' ').replace(/ {2}/g, ''),
}} />

This doesn’t fully minify the code, but it’s a nice start. If you want to squeeze every ounce of optimization out of this, you could try something like Terser to minify your code string at runtime.

Listening for Preference Changes

This is a bit of an edge case, but what if the user changes their OS dark mode preferences while they have your site open? It’s possible to listen for these changes and update your theme accordingly:

init-theme.js

// ...

const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

// Your Toggle component will need to register here to get updates.
window.__themeListeners = [];

darkQuery.addListener(e => {
  window.__setPreferredTheme(e.matches ? 'dark' : 'light');
  window.__themeListeners.forEach(l => l());
});

You’ll need a bit of extra code to register your Toggle component with these updates, but after that you should be set.


Want to see my dark mode implementation? View this site’s source code on Github.

I write about ML, Web Dev, and more topics. Subscribe to get new posts by email!



This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

This blog is open-source on Github.

At least this isn't a full screen popup

That'd be more annoying. Anyways, subscribe to my newsletter to get new posts by email! I write about ML, Web Dev, and more topics.



This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.