While a dark / light mode toggle may seem simple on the surface, it turns out that dark mode can actually cause more accessibility issues than it solves, so it's important that we allow users to toggle between light and dark themes in an accessible manner. Further, if the device / browser supports it, we should strive to display a user's preferred theme on initial load and save their preference should they decide to switch themes. On top of that we need to work around Next.js and the constraints of server side rendering (SSR). As you can see there's a fair bit to unpack, so let's jump in!
Jump ahead and view a demo, or take a look at the source code over on GitHub.
Initial setup and static elements
We'll start by bootstrapping a new instance of Next.js using the create-next-app
boilerplate.
yarn create next-app
Although we wont be utilising CSS-in-JS for the actual theming (CSS variables are going to handle that) we will leverage Emotion to style our toggle component, so let's add the necessary packages now.
yarn add @emotion/styled @emotion/react
We're using Emotion here as it supports SSR with Next.js right out the box, while Styled Components require additional config. The syntax is identical for our purposes.
create-next-app
provides a global CSS file which is the perfect location to define our CSS variables for dark and light theming.
/* styles/globals.css */body,body[data-theme="light"] {--color-text-primary: #27201a;--color-text-secondary: #076963;--color-bg-primary: #fff;--color-bg-toggle: #1e90ff;}body[data-theme="dark"] {--color-text-primary: #e3e3e3;--color-text-secondary: #ff6b00;--color-bg-primary: #15232d;--color-bg-toggle: #a9a9a9;}
As you might guess from the above CSS, we'll be switching themes by applying a data-theme
attribute to the <body>
tag, rather than the traditional ThemeProvider approach. Credit to Kent C. Dodds for bringing this technique to my attention 🙏
While we're in globals.css
let's add some basic styles that use our CSS variables so we can see our theme toggle in action later on.
/* styles/globals.css *//* ... */body {background: var(--color-bg-primary);color: var(--color-text-primary);font-family: sans-serif;transition: background 0.25s ease-in-out;}h1 {color: var(--color-text-secondary);}
create-next-app
also generates an index.js
page for us. Let's remove the bootstrapped content and enter some demo content to test our toggle button:
// pages/index.jsimport styled from "@emotion/styled";const Container = styled.div`display: flex;justify-content: center;padding-top: 35vh;`;export default function Home() {return (<Container><main><h1>Next.js dark mode toggle</h1><h4>Dark mode is more than just a gimmick, right?!</h4></main></Container>);}
Time to build out the toggle component itself. We'll create a new file called ThemeToggle.js
and place it in a components
directory.
// components/ThemeToggle.jsimport styled from "@emotion/styled";const ToggleButton = styled.button`--toggle-width: 80px;--toggle-height: 38px;--toggle-padding: 4px;position: relative;display: flex;align-items: center;justify-content: space-around;font-size: 1.5rem;line-height: 1;width: var(--toggle-width);height: var(--toggle-height);padding: var(--toggle-padding);border: 0;border-radius: calc(var(--toggle-width) / 2);cursor: pointer;background: var(--color-bg-toggle);transition: background 0.25s ease-in-out;`;const ToggleThumb = styled.span`position: absolute;top: var(--toggle-padding);left: var(--toggle-padding);width: calc(var(--toggle-height) - (var(--toggle-padding) * 2));height: calc(var(--toggle-height) - (var(--toggle-padding) * 2));border-radius: 50%;background: white;`;const ThemeToggle = () => {return (<ToggleButton type="button"><ToggleThumb /><span>🌙</span><span>☀️</span></ToggleButton>);};export default ThemeToggle;
CSS variables + calc()
functions here allow us to alter the width / height of the toggle, with all internal elements automatically resizing in proportion 🪄
Import the ToggleButton
component into the index
page...
// pages/index.jsimport styled from "@emotion/styled";import ThemeToggle from "../components/ThemeToggle";//...export default function Home() {return (<Container><main><h1>Next.js dark mode toggle</h1><h4>Dark mode is more than just a gimmick, right?!</h4><ThemeToggle /></main></Container>);}
...and we should end up with this:
Interactivity - events, state and effects
With the markup and styling out of the way it's time to add the logic. We'll start with React's useState
hook so we can store and update the active theme.
Assigning the inactive theme to a variable will also come in handy down the track.
// components/ThemeToggle.jsimport { useState } from "react";//...const ThemeToggle = () => {const [activeTheme, setActiveTheme] = useState("light");const inactiveTheme = activeTheme === "light" ? "dark" : "light";//...};
Notice the default theme is set to "light" - we'll expand on this later to include consideration for a user's prefers-color-scheme
settings, along with some smarts for persisting preferences on refresh using localStorage
Let's also add an onClick
event to the toggle button that updates our state accordingly.
// components/ThemeToggle.js//...<ToggleButtontype="button"onClick={() => setActiveTheme(inactiveTheme)}>
Next we'll leverage React's useEffect
hook to set the data-theme
attribute on the <body>
tag. Adding [activeTheme]
as a dependency means it will run anytime the active theme changes.
// components/ThemeToggle.jsimport { useState, useEffect } from "react";//...const ThemeToggle = () => {// ...useEffect(() => {document.body.dataset.theme = activeTheme;}, [activeTheme]);
Finally, we need to pass activeTheme
as a prop to ToggleThumb
so we can style the component to match the active theme.
We'll also add a transition so the button animates when toggled.
// components/ThemeToggle.js//...const ToggleThumb = styled.span`//...transition: transform 0.25s ease-in-out;transform: ${(p) =>p.activeTheme === "dark"? "translate3d(calc(var(--toggle-width) - var(--toggle-height)), 0, 0)": "none"};`;//...<ToggleButton type="button" onClick={() => setActiveTheme(inactiveTheme)}><ToggleThumb activeTheme={activeTheme} /><span>🌙</span><span>☀️</span></ToggleButton>
Ok, we should now have a basic working dark / light mode toggle button!
Accessibility considerations
Currently if we focus on our button using a screen reader, there's nothing to indicate its intended functionality. In fact, based on the emoji we've used, VoiceOver on MacOS simply dictates "sun, crescent shaped moon, button" - not a great user experience, to say the least! Let's add some aria-labels to improve upon this.
// components/ThemeToggle.js//...<ToggleButtonaria-label={`Change to ${inactiveTheme} mode`}title={`Change to ${inactiveTheme} mode`}type="button"onClick={() => setActiveTheme(inactiveTheme)}><ToggleThumb activeTheme={activeTheme} /><span aria-hidden={true}>☀️</span><span aria-hidden={true}>🌙</span></ToggleButton>
Here we've added an aria-label
that describes what action the button will perform; the title
attribute provides a tooltip when users hover the button; aria-hidden="true"
ensures screen readers will ignore the emoji ✅
Testing with an actual screen reader is preferable, although we can also inspect the <button>
element in Chrome's dev tools to ensure the correct details will be dictated via screen readers.
For more on accessible icon buttons take a look at Sara Soueidan's comprehensive article on the subject
Building on our accessibility considerations, let's add styles for focus
and hover
states.
// components/ThemeToggle.js//...const ToggleButton = styled.button`...transition: background 0.25s ease-in-out, box-shadow 0.25s ease-in-out;&:focus {outline-offset: 5px;}&:focus:not(:focus-visible) {outline: none;}&:hover {box-shadow: 0 0 5px 2px var(--color-bg-toggle);},`;
The new :focus-visible
pseudo-class allows us to remove focus for non-keyboard focus events - e.g. clicking the button with a mouse.
Persisting theme preferences
If you select 'dark' mode then hit refresh, you'll notice that the website reverts to 'light' mode. This is an easy fix thanks to the localStorage
property.
// components/ThemeToggle.js//...const ThemeToggle = () => {//...useEffect(() => {const savedTheme = window.localStorage.getItem("theme");savedTheme && setActiveTheme(savedTheme);}, []);useEffect(() => {document.body.dataset.theme = activeTheme;window.localStorage.setItem("theme", activeTheme);}, [activeTheme]);
Above we've added a new useEffect
hook that only runs on mount / unmount to check if a local storage item exists with the name 'theme'. If it does, then we set the active theme accordingly. We also update local storage any time the user toggles themes.
This is great, but if you switch to 'dark' mode and hit refresh, you might notice that we get a flash of the 'light' theme before the useEffect
kicks in. Essential this is due to Next's 'hydration' process, which you can read more about at nextjs.org.
Luckily Josh W. Comeau penned a very extensive guide on dealing with this 'flash of death' over on his blog. Our solution will be somewhat more straightforward as we're utilising CSS variables and data attributes on the <body>
tag to provide theme values, but there are still a few steps involved, so no judgement if you want to call it job done at this point. Ok, maybe some judgement... this doesn't look great:
Colour scheme preferences and tackling the dreaded flash
If you're in for the long haul, then let's start by adding a custom _document.js
file to Next.js so we can inject a <script>
tag into the <body>
. This will allow us to set the theme before Next has a chance to 'hydrate' the markup.
// pages/_document.jsimport Document, { Html, Head, Main, NextScript } from "next/document";class MyDocument extends Document {static async getInitialProps(ctx) {const initialProps = await Document.getInitialProps(ctx);return { ...initialProps };}render() {return (<Html><Head /><body><Main /><NextScript /></body></Html>);}}export default MyDocument;
Your local server requires restarting before Next will recognise the custom 'document'. Check the Next.js docs to read more about customising the 'document' page
Now we'll add some vanilla JavaScript that:
Checks to see if a user has already selected a theme by interacting with the toggle
If not, then we'll check if their browser / device has a preferred colour scheme set
Failing either of these checks we will default to 'light' mode
Finally, we save the result of the above to the
data-theme
attribute on the<body>
tag
// pages/_document.js//...render() {const setInitialTheme = `function getUserPreference() {if(window.localStorage.getItem('theme')) {return window.localStorage.getItem('theme')}return window.matchMedia('(prefers-color-scheme: dark)').matches? 'dark': 'light'}document.body.dataset.theme = getUserPreference();`;return (<Html><Head /><body><script dangerouslySetInnerHTML={{ __html: setInitialTheme }} /><Main /><NextScript /></body></Html>);}
To protect against XSS attacks, React DOM escapes any raw JavaScript before rendering. We are therefore required to embed our JavaScript using the dangerouslySetInnerHTML
attribute. In this instance it is safe as we have full control over the JavaScript being injected.
Now that we're setting the data-theme
attribute as a first order of call, we can go back to our ToggleTheme
component and refactor how it retrieves its default value.
We no longer require the initial useEffect
; instead we can now simply initialise our activeTheme
state with the value of the data-theme
attribute.
// components/ThemeToggle.js//...const ThemeToggle = () => {const [activeTheme, setActiveTheme] = useState(document.body.dataset.theme);const inactiveTheme = activeTheme === "light" ? "dark" : "light";useEffect(() => {document.body.dataset.theme = activeTheme;window.localStorage.setItem("theme", activeTheme);}, [activeTheme]);//...
If you jumped ahead and tried this out, you would have been rewarded with a big fat error!
This is because Next is attempting to render the ToggleTheme
component on the server, which has no reference to document
- it's only available to the browser.
Thankfully Next have considered this and allows certain components to be dynamically imported at the browser-level, without SSR.
Let's update the way we import our ThemeToggle
component into our index.js
page to the 'dynamic' method, with ssr
set to false
.
// pages/index.jsimport dynamic from "next/dynamic";import styled from "@emotion/styled";const ThemeToggle = dynamic(() => import("../components/ThemeToggle"), {ssr: false,});
That's it! We now have a dark mode toggle that adheres to accessibility best practices, persists on reload and takes a user's preferred colour scheme into consideration. It also doesn't suffer from the dreaded 'flash' of incorrect colours on initial load.
Bonus: as we're importing the ThemeToggle
component dynamically it won't appear if a user has JavaScript turned off, which makes sense as it would be rendered useless without JS. However, due to the beauty of CSS variables, users will still see the fallback 'light' colour scheme, even without JavaScript running - winning! 🏆
You can find the complete code example over on my GitHub, along with a demo version of the app running on Vercel.
Hit me on Twitter with any questions, comments or suggestions.
Thanks for joining and I'll catch you on the next one! ✌️