Maybe the hardest / most complicated part of building this blog was adding Dark Mode.
Not the live-embedded code snippets, not the unified GraphQL layer that manages and aggregates all content and data, not the custom analytics system, not the myriad bits of whimsy. Freaking Dark Mode.
It's a good reminder that when it comes to software development, a simple feature can have a complex implementation.
The reason that this problem is so dastardly has to do with how frameworks like Gatsby/Next.js work; the HTML is generated ahead of time. If you're not careful, you'll wind up with that telltale flicker, where the user sees the wrong colors for a brief moment.
Today we'll learn how to build the for Gatsby.js. The fundamental strategy can also be used with Next.js or any SSR app 😄
This post builds on the knowledge shared in two previous posts. You may wish to review these first if you're not yet familiar with Gatsby's compile-time build system or CSS variables:
Link to this headingOur requirements
Here's our set of criteria for this feature:
- The user should be able to click a toggle to switch between Light and Dark mode.
- The user's preference should be saved, so that future visits use the correct color theme.
- It should default to the user's "preferred" color scheme, according to their operating system settings. If not set, it should default to Light.
- The site should not flicker on first load, even if the user has selected a non-default color theme.
- The site should never show the wrong toggle state.
As we'll see, those last two are where a lot of the dragons rest 🐉.
Link to this headingCharting it out
Given those requirements, what should the initial color theme be? Here's a flow-chart:
Link to this headingA first pass
Let's write a little function that helps us update our color theme, based on the conditions we wrote about earlier.
We'll need some state! It's up to you how to manage state, but in this example, we'll use React context:
We pass our
getInitialColorMode function to
useState, to come up with our initial value. Then, we create our own helper function,
setColorMode, which updates the React state but also persists the value in localStorage, for the next load. Finally, we pass everything down to a context provider.
Link to this headingOur first hurdle
If you try building this code as-is, you'll get an error.
The problem is that our
getInitialColorMode function runs immediately on-mount, to figure out what the initial value should be. But in Gatsby, that first render doesn't happen on the user's device, a topic explored in depth in another post.
This is the crux of our problem. When React first renders, we have no way of knowing what the user's color preferences are, since that render happens in the cloud, potentially hours or days before the user visits.
We can assume that the user wants a light theme, and then swap it out after React has rehydrated on the client… but if we do that, we'll wind up with the dreaded flicker:
Ideally, the HTML we send to the user should already have the right colors, even on the very first frame it's painted. But we don't know what colors the user wants until it's on their device! Is a solution even possible?
Link to this headingA workable solution
Here's our solution, at a high level:
- Use CSS variables for all of our styling
- When we're generating the HTML at compile-time, inject a
<script>tag before all of our content (the page itself)
- In that script tag, work out what the user's color preferences are
We're taking advantage of a few tricks in order to achieve our goal. Here's what the initial HTML looks like (before our JS bundle has been executed):
Link to this headingBlocking HTML
<script> tag goes before our main body content. This is important, since scripts are blocking; nothing will be painted to the screen until that JS code has been evaluated.
For example: check out what the user sees when the following HTML is run:
Notice that the
<h1> isn't shown until the
<script> has finished running. 😮
Link to this headingReactive CSS variables
CSS variables are really cool. Their best trick is that they're . When a variable's value changes, the HTML updates instantly.
We can use CSS variables in all of our components. For example, using styled-components:
This is the core of our strategy: rely on CSS variables for styling, and then pre-empt the HTML by tweaking the CSS variable values.
Link to this headingUpdating HTML in Gatsby
When Gatsby builds, it produces an HTML file for each page on our site. Our goal is to inject a
<script> tag above that content, so that the browser parses it first.
One of the really cool things about Gatsby is that it exposes escape hatches at every step in its build process. We can hook into it, and add a bit of custom behaviour!
If you don't already have it, create a
gatsby-ssr.js file in your project's root directory.
gatsby-ssr.js is a file which will run when Gatsby is compiling your site (at build time).
We'll add the following code:
There's a lot going on here, so let's break it down:
onRenderBodyis a lifecycle method that Gatsby exposes. It will run this function when generating our HTML during the build process.
setPreBodyComponentsis a function which will inject a React element above everything else it builds (our actual site), within the
MagicScriptTagis a React component, and it renders a
<script>tag. We pass it a stringified snippet and use
dangerouslySetInnerHTMLto embed that script in the returned element.
- We use an IIFE (rocking it oldschool!) to avoid polluting the global namespace.
Link to this headingCrossing the chasm
You may wonder why we're putting our code in a string and rendering it. Can't we just call that function normally?
I like to think of this as a "chasm" in space and time. There's the moment where we build our code, on our computer or in the cloud. And then there's the moment the client runs our code, on their device, at a very different place and time.
We want to write some code that will be injected at compile-time, but only executed at run-time. We need to pass that function as if it was a piece of data, to be run on the user's device.
We have to do it this way because we don't have the right information yet; we don't know what's in the user's localStorage, or whether their operating system is running in dark mode. We won't know that until we're running code on the user's device.
The opposite is also true! When this code eventually runs, it won't have access to any of our bundled JS code; it will run before the bundle has even been downloaded! That means it won't automatically know what our design tokens are.
Link to this headingGenerating the script
By using some string interpolation, we can "generate" the function we'll need:
We move our
getInitialColorMode function into this string (we can't simply import it and call it! We need to copy/paste it). Once we know what the initial color should be, we can start setting our CSS variables. We use string interpolation, so that the actual script being injected will look like:
To keep things as straightforward as possible, we're calling
root.style.setProperty manually for every color in our site. As you might imagine, this gets a little tedious when you have dozens of colors! We'll talk about potential optimizations in the appendix.
We also set one last property,
--initial-color-mode. This is a potato we're passing from this runtime script to our React app; it will read this value in order to figure out what the initial React state should be.
Link to this headingState management
If we didn't want to give users the option to toggle between light and dark mode, our work would be done! The initial state is perfect.
No dark mode is complete without a toggle, though. We want to let users pick whether our site should be light or dark! We can make an educated guess based on their operating system's settings, but just because a user likes a dark OS doesn't mean they want our website in dark colors, and vice versa.
Here's how we capture that state in React:
To highlight relevant bits:
- We're initializing the state with
undefined. This is because for the first render (at compile-time), we don't have any access to the
- Immediately after the React app rehydrates, we grab the root element, and check what its
--initial-color-modeis set to.
- We set this into our state, so that now our React state has inherited the CSS variable's value we set in
Link to this headingOrder of operations
It can be hard to visualize this sequence of events, so I built a little interactive gismo. Tap or hover over each step to view a bit more context.
Link to this headingAdding a toggle
We've set up our state management code; let's use it to build a toggle!
When the toggle is triggered, here's what we'll need to do:
- Update the React state that tracks the current color mode.
- Update localStorage, so that we remember their preferences for next time.
- Update all the CSS variables, so that they point to different colors.
Here's what that handler looks like:
Again, to keep things as simple as possible, we aren't doing any fancy iteration to generate the
For my blog, I built a fancy animated toggle, which morphs between a sun and a moon:
Building this toggle is beyond the scope of this tutorial, though I will be writing about it soon (subscribe to my newsletter if you'd like to be notified when it's released!). We'll look at how to use React Spring and SVG masks to build awesome UI flourishes like this.
For now, in order to focus on the logic, we'll stick with a rather more understated toggle:
Here's how it works:
We have a checkbox, and we set its value to
colorMode, pulled from context. When the user toggles the checkbox, we call our
setColorMode function with the alternative color mode.
There's a problem though! When we build our site for production, our checkbox doesn't always initialize in the right state:
Remember, the initial render happens in the cloud at compile-time, so
colorMode will initially be
undefined. Every user gets the same HTML, and that HTML will always come with an unchecked checkbox.
Our best bet is to defer rendering of the toggle until after the React app knows what the
colorMode should be:
By not rendering anything on the first compile-time pass, we leave a blank spot that can be filled in on the client, when that data is known to React:
Link to this headingSuccess! 🌈
We've reached a solution that ticks all of our boxes: our users see the correct color scheme from the very first frame, and they're never shown a toggle in the wrong state.
I've published a repo with this solution on Github. It takes a few liberties when it comes to optimizations and cleanups, explored in the appendix below, but the core ideas are all the same. Feel free to dig into it!
Implementing a no-compromises Dark Mode is non-trivial, but I think it's worth the trouble. Little details matter, and it's especially important to avoid UI glitches in the first few seconds of a user's visit!
Link to this headingAppendix: Tweaks
There are a few more small tweaks and optimizations I've made to the solution. Let's talk about them!
Link to this headingIteration
In our example, we wind up repeating the
setProperty code in two places:
gatsby-ssr.js, when creating the initial variables.
- Inside our
ThemeProvidercomponent, when toggling modes.
The annoying thing with this duplication is that you need to remember to update both spots when you add or change a design token. We can fix that by generating them dynamically:
This is a nice little win because you can tweak colors and sizes without having to think about this process at all.
The exact code you'll need depends on the structure of your design tokens.
One of the neat things about Gatsby is that many Gatsby sites work with JS disabled. Our current solution doesn't account for that; if you visit this site without JS enabled, everything is rendered properly, but there are no colors 😱
We can fix this by injecting a
<style> tag into the
<head> of our document, during the build in
gatsby-ssr.js. In the same way that we inject a script tag to tweak the colors at runtime, we can inject a style tag to set defaults that will be used in the event that JS is disabled.
Here's a quick and dirty example:
You could generate each key/value pair dynamically to avoid the duplication of styles.
This fix was added to the example repo, so check out a "real-world" example there!
Link to this headingMinification
For a long time now, JS code has gone through a process of “minification” or “uglification”; we take perfectly legible code and garble it so that it takes up the least amount of space possible.
This happens automatically when using build systems like Gatsby or Create React App, but it isn't happening for that little script we inject! That work happens outside the module build system; webpack doesn't know about it.
I tried using a dependency called Terser. It takes a string of source code and performs a number of operations to make it smaller. You use it like this:
I tried this on my blog, and the difference was pretty negligible (~200 bytes). Your mileage may vary, depending on how much work you're doing in that injected script!
Link to this headingScript generation
gatsby-ssr, we inject a script tag, and we do so by providing a string that will be executed later as a function.
Writing a function within a string is no fun, though; we don't get any sort of static checking, no Prettier support, no squiggly red underlines when we typo something.
We can get around this by writing a function the traditional way, but then stringifying it:
This has some gotchas though!
- We want to use this function as an IIFE, to prevent leaking into the global state
- We need to somehow pass it our design tokens! Unlike "regular" functions, we won't be able to rely on parent scopes in this case, since it will be executed in an entirely different context.
I solved both of these woes by making some tweaks after the stringification:
This last tweak feels a bit like a wash to me; we've gained IDE support, but we've made our code a fair bit more complicated / unintuitive. I opted to keep it because I like the 🌈 emoji, but you might prefer to make different tradeoffs 😄
There are lots of potential optimizations and tweaks, but at the end of the day, the most important thing is the user experience, and we've achieved a solid one with this solution ✨