Theme toggle on a Gatsby + MDX site
27 Nov 2021
I recently read Josh Comeau's excellent article on how he was able to achieve a theme toggle on his Gatsby site. I find his solution to be excellent! However my Gatsby site is, for better or worse, pretty tied to MDX and React. I implemented a solution based on his for my current site theme.
Prereq: Update libraries
Before I started this feature, I had to update my blog... and its about 2 years behind 😬. This meant Gatsby & plugins needed two major version upgrades. While at it, I ditched CSS Modules for Tailwind and then refactored all the components to use these utility classes. With that out of the way...
Step 1: Updating HTML in Gatsby
Using the Gatsby SSR setPreBodyComponents
technique Josh described, my implementation just had slightly different code to run on the client so for brevity that's all I'll include.
const codeToRunOnClient = `
window.theme = {
init: () => {
window.theme.set(window.theme.current())
},
current: () => localStorage.getItem('theme:dark') === 'true',
set: (to) => {
document.documentElement.classList.toggle('dark', to);
const result = document.documentElement.classList.contains('dark');
localStorage.setItem('theme:dark', result);
return result
},
toggle: () => window.theme.set(!window.theme.current())
}
window.theme.init();
`;
My site doesn't have a problem with globals since I control the libraries used on my blog. Maybe I'll change this if it becomes a problem in the future.
I created following functions on a global theme
object:
init
runs on the client to initialize the theme (aka make sure the html element has the right className) before the rest of HTML is renderedcurrent
returns the current theme state, which is stored in localStorageset
toggles the class to the HTML element that controls the theme, and updates the localStorage state based on whether that was successful, and returns the current state for React to store/synctoggle
is basically just a wrapper aroundset
that negates thecurrent
value, for convenience
The set
logic was useful because I was seeing behavior where the classList.toggle
would actually fail to add the class for whatever reason. This approach ensured the state was always correct based on the dom.
Step 2: Crossing the chasm
The helper functions are setup before any of the rest of the page so once React kicks in I simply wire it up to the ThemeToggle component:
export default function ThemeToggle() {
const [darkModeEnabled, setDarkModeEnabled] = useState(() =>
window.theme.current(),
);
let handleThemeToggle = () => {
setDarkModeEnabled(window.theme.toggle());
};
return (
<label>
<input
type="checkbox"
checked={darkModeEnabled}
onChange={handleThemeToggle}
/>
</label>
);
}
Some code was omitted just to highlight the relevant parts. When the component mounts it syncs the theme.current()
state, and onChange it calls theme.toggle()
and updates local state.
SSR
Gatsby won't let you forget about SSR! window
is not defined when server-side rendering so we have to handle it by checking if we're in a browser environment and short circuiting if true.
// Check if in browser environment
const isBrowser = typeof window !== 'undefined';
export default function ThemeToggle() {
const [darkModeEnabled, setDarkModeEnabled] = useState(() => {
// Short circuit if not in browser
if (!isBrowser) return false;
else return window.theme.current();
});
// ... rest of code
}
The rest of the code doesn't get executed on the server so it doesn't need updating.
Step 3: Adding a toggle
Last step is to build the markup for the theme toggle. I found this great Build a CSS only toggle switch using TailwindCSS article that was very well executed, built with accessibility and semantics in mind. I just copy-and-pasted the code and modified the colors and sizing.
That's it! I'm pretty happy with my site redesign, and this ThemeToggle was a huge bonus!