Implementing a light/dark mode toggle with app router + RSC #53063
Replies: 5 comments 16 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.
-
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