TL;DR Add as many color themes as you like to your react app, using a tiny react hook and CSS custom properties.
Over the last few weeks, I've been upgrading my website with a complete redesign, including dark-mode functionality. I've found some good resources to add a dark-mode / light -mode switcher, but very little info to do proper theming with more than just two themes.
That's why I decided to build a new feature for my site: use-color-theme.
A simple react hook that toggles light-theme
, dark-theme
and any other
class on the body
tag. The hook works with CSS custom
properties and uses
prefers-color-scheme
and localStorage under the hood to match users
preferences and eliminate the flash problem that's often associated with
color theming.
Now adding a new color theme happens in just a few steps. Check it out on my site by hitting the theme icon in the header.
Adding multiple themes has never been easier. Just follow the simple steps and you can add theming to your site. Let's create an example page to go through the steps or click here to jump straight to the add it to a page part.
First, we create a new directory and install the basics.
1mkdir colorful && cd colorful2yarn init -y3yarn add react react-dom next
Next, we create the pages
folder required for NextJs
and create two files: _app.js
and index.js
.
Let us also add some basics to make it look pretty.
1export const _App = ({ pageProps, Component }) => {2 return (3 <>4 <style jsx global>{`5 html,6 body {7 padding: 0;8 margin: 0;9 font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Ubuntu, Cantarell, Fira Sans, Helvetica Neue, sans-serif;10 }1112 body {13 background-color: #fff;14 }1516 a {17 color: inherit;18 text-decoration: none;19 }2021 * {22 box-sizing: border-box;23 }2425 header {26 height: 100px;27 position: sticky;28 top: 0;29 margin-top: 32px;30 background-color: #fff;31 }3233 nav {34 max-width: 760px;35 padding: 32px;36 display: flex;37 justify-content: flex-end;38 align-items: center;39 margin: 0 auto;40 }4142 button {43 border: 0;44 border-radius: 4px;45 height: 40px;46 min-width: 40px;47 padding: 0 8px;48 display: flex;49 justify-content: center;50 align-items: center;51 background-color: #e2e8f0;52 cursor: pointer;53 color: #fff;54 margin-left: 16px;55 }5657 button:hover,58 button:focus,59 button:active {60 background-color: var(--button-bg-hover);61 outline: none;62 }63 `}</ style>64 <header>65 <nav>66 <button>Toggle</button>67 </nav>68 </header>69 <Component {...pageProps} />70 </>71 )72}7374export default _App
1export default function Index() {2 return (3 <>4 <style jsx>{ `5 .wrapper {6 max-width: 760px;7 padding: 0 32px;8 margin: 0 auto;9 }10 `}</ style>11 <main className="page">12 <div className="wrapper">13 <h1 className="intro">Hello World!</h1>14 <p>15 Lorem ipsum dolor sit amet, consectetur adipisicing elit. Adipisci animi consectetur delectus doloreeligendi id illo impedit iusto, laudantium nam nisi nulla quas, qui quisquam voluptatum? Illo nostrum oditoptio.16 </p>17 </div>18 </main>19 </>20 )21}
Let's add some CSS custom properties for the theme styling.
1...2<style jsx>{`3 .wrapper {4 max-width: 760px;5 padding: 0 32px;6 margin: 0 auto;7 }89 h1 {10 color: var(--headings);11 }1213 p {14 color: var(--text)15 }16`}</ style>17...
In the _app.js file, we can then add the global CSS variables with its different colors. You can also add the CSS properties with any other css-in-js framework or plain css files, as long as the classes are matched accordingly
Let's also swap the colors used for the header so we use CSS properties across the board.
1...2 <style jsx global>{`3 ...4 body {5 background-color: var(--background);6 }78 header {9 height: 100px;10 position: sticky;11 top: 0;12 margin-top: 32px;13 background-color: var(--background);14 backdrop-filter: blur(10px);15 }1617 nav {18 max-width: 760px;19 padding: 32px;20 display: flex;21 justify-content: flex-end;22 align-items: center;23 margin: 0 auto;24 }2526 button {27 border: 0;28 border-radius: 4px;29 height: 40px;30 width: 40px;31 display: flex;32 justify-content: center;33 align-items: center;34 background-color: var(--button-bg);35 transition: background-color 0.2s ease-in;36 cursor: pointer;37 color: var(--headings)38 }3940 button:hover, button:focus, button:active {41 background-color: var(--button-bg-hover);42 outline: none;43 }4445 body {46 --button-bg: #e2e8f0;47 --button-bg-hover: #cdd7e5;48 --background: #fff;49 --headings: #000;50 --text: #38393e;51 }52`}</ style>
Add the custom hook by running yarn add use-color-theme
in the terminal and implement it in our _app.js file. This will make sure that the themes are available globally on each page.
1import useColorTheme from 'use-color-theme'23export const _App = ({ pageProps, Component }) => {4 const colorTheme = useColorTheme('light-theme', {5 classNames: ['light-theme', 'dark-theme', 'funky']6 })7 return (8 <>9 <style jsx global>{`10 ...11 .light-theme {12 --button-bg: #e2e8f0;13 --button-bg-hover: #cdd7e5;14 --background: #fff;15 --headings: #000;16 --text: #38393e;17 }1819 .dark-theme {20 --button-bg: rgb(255 255 255 / 0.08);21 --button-bg-hover: rgb(255 255 255 / 0.16);22 --background: #171923;23 --headings: #f9fafa;24 --text: #a0aec0;25 }2627 .funky {28 --button-bg: #1f2833;29 --button-bg-hover: #425069;30 --background: #0b0c10;31 --headings: #66fcf1;32 --text: #e647ff;33 }34 `}</ style>35 <header>36 <nav>37 <button onClick={colorTheme.toggle}>Toggle</button>38 </nav>39 </header>40 ...41 </>42 )43}4445export default _App
Having a look at the detail to see what's happening.
- We import useColorTheme and impiment it the same way we would use any other react hook:
1const colorTheme = useColorTheme('light-theme', {2 classNames: ['light-theme', 'dark-theme', 'funky']3})
The 1st parameter is the initial class, which will be used if nothing else has been selected yet. A second parameter is an Object with the configuration for the hook. you can name the classes in any way you like, but semantic names are recommended
We added classes for
.light-theme
,.dark-theme
and.funky
with different color variables.We added an onClick function to the button with
colorTheme.toggle
But what if I want to change it to a specific theme?
There's an easy solution to that as well. Let us have a look how we can implement it:
1...2<nav>3 <button onClick={() => colorTheme.set('light-theme')}>Light</button>4 <button onClick={() => colorTheme.set('dark-theme')}>Dark</button>5 <button onClick={() => colorTheme.set('funky')}>Funky</button>6 <button onClick={() => colorTheme.toggle()}>Toggle</button>7</nav>8...
Now we are all set and can easily change the themes in any way we like. But what happens when we refresh the page? Check it out.
As you see, when refreshing the page, the theme stays the same as before, but there is a split second of a white flash. That's because the user-preference is stored in localStorage and only accessed during the react hydration. Luckily, there is a solution to that as well.
We can set up a code blocking script that completes loading before anything else can be executed. Lets create a file for the script mkdir public && cd public
and create the file with touch colorTheme.js
and copy the below code into the file.
1// Insert this script in your index.html right after the <body> tag.2// This will help to prevent a flash if dark mode is the default.34;(function () {5 // Change these if you use something different in your hook.6 var storageKey = 'colorTheme'7 var classNames = ['light-theme', 'dark-theme', 'funky']89 function setClassOnDocumentBody(colorTheme) {10 var theme = 'light-theme'11 if (typeof colorTheme === 'string') {12 theme = colorTheme13 }14 for (var i = 0; i < classNames.length; i++) {15 document.body.classList.remove(classNames[i])16 }17 document.body.classList.add(theme)18 }1920 var preferDarkQuery = '(prefers-color-scheme: dark)'21 var mql = window.matchMedia(preferDarkQuery)22 var supportsColorSchemeQuery = mql.media === preferDarkQuery23 var localStorageTheme = null24 try {25 localStorageTheme = localStorage.getItem(storageKey)26 } catch (err) {}27 var localStorageExists = localStorageTheme !== null28 if (localStorageExists) {29 localStorageTheme = JSON.parse(localStorageTheme)30 }31 // Determine the source of truth32 if (localStorageExists) {33 // source of truth from localStorage34 setClassOnDocumentBody(localStorageTheme)35 } else if (supportsColorSchemeQuery) {36 // source of truth from system37 setClassOnDocumentBody(mql.matches ? classNames[1] : classNames[0])38 localStorage.setItem(storageKey, JSON.stringify('dark-theme'))39 } else {40 // source of truth from document.body41 var iscolorTheme = document.body.classList.contains('dark-theme')42 localStorage.setItem(storageKey, iscolorTheme ? JSON.stringify('dark-theme') : JSON.stringify('light-theme'))43 }44})()
This script does the following:
- It looks for the
localStorage
with the keycolorTheme
- Then it looks for the
prefers-color-scheme
CSS media query, to check whether its set to dark, which translates to the user loading the website having a system using dark mode.- If there's no mode set in localStorage
but the user's system uses dark mode, we add a class
dark-theme
to the body of the main document. - If there's nothing set in localStorage we don't do anything, which will end up loading the default theme of our Site.
- Otherwise, we add the class associated with the mode set in local storage to the body of the document
- If there's no mode set in localStorage
but the user's system uses dark mode, we add a class
The last thing we then need to do is to load the script during page load. We want to make sure that the script runs after our meta tags are loaded, but before the content of the page get loaded. In Next.js we can use the
_document.js
file to load the script before the main content & after the
<head></head>
(check out the docs for more info).
1import Document, { Head, Html, Main, NextScript } from 'next/document'23class _Document extends Document {4 render() {5 return (6 <Html>7 <Head></Head>8 <body>9 <script src="./colorTheme.js" />10 <Main />11 <NextScript />12 </body>13 </Html>14 )15 }16}1718export default _Document
By adding the script to the body
before any other content is loaded, we avoid the flash successfully. You can find the code here.
Let me know what you think of it and try and create your own color-themes.