Implementing a light/dark mode toggle with app router + RSC #53063
Replies: 7 comments 20 replies
-
This meets your requirements 1-4 (and maybe 5), as long as your theme CSS can be controlled by
import { cookies } from 'next/headers';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const cookieStore = cookies();
const theme = cookieStore.get('theme'));
return (
<html lang="en" data-theme={theme?.value}>
<body>
<ThemeToggle initialValue={theme?.value as ('light' | 'dark')} />
{children}
</body>
</html>
);
}
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
function ThemeToggle({ initialValue }: { initialValue: Theme }) {
const [theme, setTheme] = useState(initialValue);
useEffect(() => {
if (theme) {
document.cookie = `theme=${theme};path=/;`;
document.querySelector('html').setAttribute('data-theme', theme);
} else {
setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
}, [theme]);
return (
<button type="button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle Theme
</button>
);
}
export default ThemeToggle; I'm not sure if it is the best way but it worked for me. The main problem to be overcome is that when the user picks a theme it needs to be known on the server to avoid the potential FOUC when This overcomes that, the main problem I guess is whether using a cookie for it is the "right" way, or maybe you need to avoid cookies. There's one caveat I noticed which relates to your 5th requirement, which is present in my actual use case but not in the demo above: when You could return null or some loading state until the effect runs and the theme has a value to avoid that. |
Beta Was this translation helpful? Give feedback.
-
Example Live Site URL : https://nextjs-app-darkmode.vercel.app/ This solution by Dan Abramov meets most of the requirements.
const code = function () {
window.__onThemeChange = function () {};
function setTheme(newTheme) {
window.__theme = newTheme;
preferredTheme = newTheme;
document.documentElement.dataset.theme = newTheme;
window.__onThemeChange(newTheme);
}
var preferredTheme;
try {
preferredTheme = localStorage.getItem('theme');
} catch (err) {}
window.__setPreferredTheme = function (newTheme) {
setTheme(newTheme);
try {
localStorage.setItem('theme', newTheme);
} catch (err) {}
};
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkQuery.addEventListener('change', function (e) {
window.__setPreferredTheme(e.matches ? 'dark' : 'light');
});
setTheme(preferredTheme || (darkQuery.matches ? 'dark' : 'light'));
};
export const getTheme = `(${code})();`;
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { getTheme } from '../lib/getTheme';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<head>
<script dangerouslySetInnerHTML={{ __html: getTheme }} />
</head>
<body className={inter.className}>{children}</body>
</html>
);
}
'use client';
import { useState, useEffect } from 'react';
const SetTheme = () => {
const [theme, setTheme] = useState(global.window?.__theme || 'light');
const isDark = theme === 'dark';
const toggleTheme = () => {
global.window?.__setPreferredTheme(theme === 'light' ? 'dark' : 'light');
};
useEffect(() => {
global.window.__onThemeChange = setTheme;
}, []);
return <button onClick={toggleTheme}>{isDark ? 'dark' : 'light'}</button>;
};
export default SetTheme;
import dynamic from 'next/dynamic';
const SetTheme = dynamic(() => import('../components/SetTheme'), {
ssr: false,
});
export default function Home() {
return (
<main>
...
<SetTheme />
...
</main>
);
}
:root {
color-scheme: light;
...
} :root[data-theme='dark'] {
color-scheme: dark;
...
} Reference:
|
Beta Was this translation helpful? Give feedback.
-
@Hugomndez Thanks for sharing this. The idea of exporting the getTheme function as a string was brilliant. |
Beta Was this translation helpful? Give feedback.
-
Great implementation for now... |
Beta Was this translation helpful? Give feedback.
-
@Hugomndez thanks for this. |
Beta Was this translation helpful? Give feedback.
-
None of these solutions are "clean", they all either happen too late or they all use suppressHydrationWarning (which is less than ideal). 😞 Hoping for a cleaner solution from the Next.js team. 🍻 |
Beta Was this translation helpful? Give feedback.
-
Using the cookies answer above as a base, I think I found a relatively simple solution to this issue, adding in React Context. We can include a Context Provider in the layout and still have all the children be server or client components, as only the children that consume the context need to be client components. Without context, my toggle button would flicker on page load and route changes. I'm using data-theme and CSS variables with Tailwind to manage the styles, and I'm sourcing the initial theme from my CMS (overriding this if a user has visited the site and chosen their theme). Full solution and example site here: https://www.mandalinedev.com/articles/color-themes-next-js // src/app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps ) {
const client = createClient();
const settings = await client.getSingle("settings");
const cookieStore = cookies();
const theme = cookieStore.get('theme');
return (
<html
lang="en"
className={`${nunito_sans.className} ${fira_mono.className}`}
data-theme={theme?.value ?? settings.data.theme}
>
<body className="overflow-x-hidden antialiased">
<main>
<ThemeProvider initialTheme={theme?.value ?? settings.data.theme}>
{children}
</ThemeProvider>
</main>
</body>
</html>
);
} // src/app/theme-provider.tsx
'use client';
import { createContext, useState, ReactNode, useEffect } from 'react';
import { setCookie } from 'cookies-next';
interface ThemeContextType {
theme: string;
setTheme: (theme: string) => void;
}
export const ThemeContext = createContext<ThemeContextType>({
theme: 'Turquoise',
setTheme: () => {},
});
interface ThemeProviderProps {
children: ReactNode;
initialTheme: string;
}
export default function ThemeProvider({
children,
initialTheme
}: ThemeProviderProps) {
const [theme, setThemeState] = useState(initialTheme);
const setTheme = (newTheme: string) => {
setThemeState(newTheme);
setCookie('theme', newTheme, { path: '/', maxAge: 7 * 24 * 60 * 60 });
document.documentElement.setAttribute("data-theme", newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
} // ThemeToggle.tsx
'use client';
import { useContext } from "react";
import { ThemeContext } from "@/app/theme-provider";
const themes = ["Turquoise", "Violet", "Purple"];
const ThemeSelector = () => {
const { theme: selectedTheme, setTheme } = useContext(ThemeContext);
const handleThemeChange = (theme: string) => {
setTheme(theme);
};
const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(' ');
};
return (
<div>
<nav className="flex space-x-4" aria-label="Tabs">
{themes.map((theme) => (
<button
key={theme}
onClick={() => handleThemeChange(theme)}
className={classNames(
selectedTheme === theme
? 'text-highlight-light bg-secondary-dark'
: 'text-primary-light hover:text-highlight-light',
'transition-colors rounded-lg px-4 py-1 text-sm tracking-tight'
)}
aria-current={selectedTheme === theme}
>
{theme}
</button>
))}
</nav>
</div>
);
};
export default ThemeSelector; |
Beta Was this translation helpful? Give feedback.
-
It is extremely difficult to implement a light/dark mode toggle with app router + RSC. People are stumbling over every piece of the puzzle and there are numerous issues/discussions about the problems the different approaches have. If this is already possible, it would be great if we could get an official example to serve as a reference implementation. And if not, could we get the bugs fixed that block such a reference implementation?
The requirements that make this hard are as follows:
display:none
until the React component renders. The only JS that blocks render should be the few lines of JS we inline into the<head>
that sets the light/dark mode attribute on html.Here's a summary of the issues I'm aware of:
<head>
but I don't see a way to do that with the app router.<Script>
does not run early enough, not even withbeforeInteractive
. There are threads where people have claimed that<script>
will load at the bottom of head, but I am not observing that. In short: how do we reliably inject a few lines of JS into the head, in both production and development builds? Example threads:beforeInteractive
strategy ignores additional attributes in app router #49830suppressHydrationWarning
which feels like a pretty heavy hammer and can potentially cause us to miss other real issues that get suppressed. Example threads:ssr:false
makes it easy to write an isomorphic component, but the component loads too late and there are bugs with dynamically loaded content.There are a lot of solutions floating around but I haven't found one that actually meets the requirements above. For example:
Accept-CH
header, but that doesn't work on first load and isn't supported by Firefox/Safari.Beta Was this translation helpful? Give feedback.
All reactions