This repository is a separate implementation of the Dark Mode in Joy of React. This differs by relying on CSS Variables and uses prefers-color-scheme
API to detect the user's preference for first time visitors.
This is made for Next.js /app
router. But the methodology should work with any React framework that generates html
on the server.
Let's start by creating a simple toggle without user preference detection.
- Creating a component for toggling theme. /components/theme-toggle.tsx
'use client'
import React from 'react'
import Cookie from 'js-cookie'
export const ThemeToggle = ({ initialTheme }: { initialTheme: string }) => {
const [theme, setTheme] = React.useState(initialTheme)
function handleClick() {
// get next theme
const newTheme = theme === 'light' ? 'dark' : 'light'
// set theme for local state
setTheme(newTheme)
// set cookies for next visit
Cookie.set('theme-color', newTheme, {
expires: 1000,
})
// set theme for html
document.documentElement.setAttribute('data-theme-color', newTheme)
}
return (
<button onClick={handleClick}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
)
}
We are using js-cookie to handle cookies. You can use any library or write your own.
- In
/app/layout.tsx
, we will look for the cookie and set the theme for the visitors.
import { cookies } from 'next/headers'
import { ThemeToggle } from './components/theme-toggle'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const savedTheme = cookies().get('theme-color')
// if no cookie, set theme to light
const theme = savedTheme?.value || 'light'
return (
<html lang="en" data-theme-color={theme}>
<body>
<header className="header">
<ThemeToggle initialTheme={theme} />
</header>
{children}
</body>
</html>
)
}
Now that we have a working toggle, let's add user preference detection. We will use prefers-color-scheme
API to detect the user's preference for visitors.
- In
/app/layout.tsx
, we will inject a script in the head that will run on the client.
const ThemeScript = () => {
const codeToRunOnClient = `
(function() {
if(document.documentElement.getAttribute('data-theme-color') === 'system' && window.matchMedia) {
const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme-color', theme)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
const newColorScheme = event.matches ? "dark" : "light";
document.documentElement.setAttribute('data-theme-color', newColorScheme)
});
}
})()
`
return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />
}
We are using IIFE to avoid polluting the global namespace.
Injecting the script is intentional, we are XSSing ourselves.
- Now we will add the script to the head and also change default theme to
system
.ThemeScript
will detect that the theme issystem
and will set the theme based on user preference.
...
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const savedTheme = cookies().get('theme-color')
const theme = savedTheme?.value || 'system'
return (
<html lang="en" data-theme-color={theme}>
<head>
<ThemeScript />
</head>
<body className={inter.className}>
<header className="header">
<ThemeToggle initialTheme={theme} />
</header>
{children}
</body>
</html>
)
}
- This works but we need to sync the toggle with the user preference.
// /components/theme-toggle.tsx
React.useEffect(() => {
if (initialTheme === 'system') {
setTheme(
document.documentElement.getAttribute('data-theme-color') || 'light'
)
}
}, [initialTheme])
- Also change the button to reflect the user preference. Everthing else will remain the same.
if (theme === 'system') {
return <button className={styles.toggle}>Loading</button>
}
return (
<button onClick={handleClick} className={styles.toggle}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
)
We are using CSS Variables to set the theme. It's upto you on how to manage the variables. One way is to create a /tokens.css
file and import it in /globals.css
.
/* /tokens.css */
:root,
[data-theme-color='light'] {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
[data-theme-color='dark'] {
color-scheme: dark;
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
/* /globals.css */
@import './tokens.css';
...
Since we are using data attributes to set the theme, we can use CSS to change the styles based on theme. We cannot rely on @media (prefers-color-scheme: dark)
anymore.
[data-theme-color='dark'] {
.logo {
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
}
}
Work in progress
General idea is to use classes to set the theme instead of data-theme-color
. Then in tailwind.config.js
, set the dark mode property to class:
module.exports = {
darkMode: 'class',
}
Using dark mode classes is possible then:
<div className="bg-white dark:bg-black"></div>