The best result I came across was here and he credits this Kent Dodds article here for using CSS variables with the custom data attribute over a theme provider from your CSS-in-JS library.
Custom data attribute: In this situation a “data-theme” attribute is set on the body.
Notice: There is a duplicate selector on the body tag, likely due to light being the default
body, body[data-theme="light"] {...}
body[data-theme="dark"] {}
In other implementations I have seen the default set on the “:root” that acts as default light theme and a stand-alone “[data-theme=”dark”]” that is being applied to the document.
:root {...}
[data-theme="dark"] {...}
Remember: It’s Document > Root Element (HTML) the elements of head and body children of that.
This was the previous implementation I’ve seen with the “:root and data[]” approach:
useEffect(() => {
document.documentElement.setAttribute("data-theme", localStorage.getItem("theme") || "")
setTheme(localStorage.getItem("theme") || "")
}, [])
And here was the way described in the article:
useEffect(() => {
document.body.dataset.theme = activeTheme;
}, [activeTheme]);
Essentially the same thing.
Note: The dataset JavaScript is a document-oriented module (DOM) property to access the data attribute and set it on the JavaScript element. It is a DOM interface to set data elements on the application using JavaScript language.
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>
);
}
This allows use to refactor the initial value for “useState” and drop the “useEffect” with empty dependency array.
Server Error: ReferenceError: document is not defined.
So we have to use Next JS dynamic import. So the theme toggle button needs to be it’s own component.
Persisting theme switcher state on page refresh in a Next JS app is a bit more complicated than just reaching for local storage. Local storage works with the help of the “useEffect” hook to ensure you are on the client.
useEffect(() => {
const value = localStorage.getItem("themePref")
if (typeof value === "string") {
appDispatch({ type: "setLightTheme" })
}
return () => {
console.log("cleanup")
}
}, [])
useEffect(() => {
console.log("appState.theme changed")
if (appState.theme !== "dark") {
localStorage.setItem("themePref", JSON.stringify(appState.theme))
}
if (appState.theme === "dark" && typeof localStorage.getItem("themePref") === "string") {
localStorage.removeItem("themePref")
}
return () => {
console.log("teardown")
}
}, [appState.theme])
But you end up with a pretty nasty flashing result.
So the answer is to use cookies over local storage.
Note: You don’t want to store any user-sensitive data in a “non-httpOnly” cookie. But a theme switcher state preference is fine.
Heres’s the code, using the “js-cookie” package:
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
if (Cookies.get("themePref")) {
appDispatch({ type: "setLightTheme" })
}
}, [])
useEffect(() => {
if (appState.theme === "light") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
Cookies.set("themePref", appState.theme, { expires: 7 })
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
if (appState.theme === "dark" && Cookies.get("themePref")) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
Cookies.remove("themePref")
}
}, [appState.theme])
But the flashing persists here too.
So you need to read the cookie on the server and you can do this inside of a “getServerSideProps” function in Next JS. But this is only available in page components.
The best result I came across was here. Very impressive. Also interesting it didn’t even bother using a theme provider.
I liked this article here but obscufates the custom hook.
This article here about Gatsby pointed me to a Next JS implementation which comment’s mentioned to Inject a script in _document.js here