Color mode with nextjs

Posted 30.12.2023 ยท 6 min read

In the process of moving this site over to a new design made with Tailwind CSS I wanted the dark mode variant to work without a flash of the light mode variant, at least for repeat visits.

At first I looked into media features client hints headers , which has a header Sec-CH-Prefers-Color-Scheme with hint of the preferred color-scheme. I quickly realised it would not be enough since it is currently only supported by chromium based browsers. So I decided to roll my own cookie for it, but prefer the hint header if it is present.

In tailwind classes defines the active mode, either .light, .dark. This can be set anywhere in the element hierarchy, since I need it for the background of the body tag I set it on the html tag. Thus, the goal is to make sure the class is set correctly in the html sent by Next.js. This will require all pages to be server side rendered instead of statically generated.

Server side

To be able to set the class on the server side of things it is necessary to override the next document component. Using __NEXT_DATA__ is probably not the best of ideas, but it will get the color mode from the page props.

import Document, { Html, Main, NextScript } from "next/document";
class MyDocument extends Document {
render(): React.ReactElement {
const mode = this.props.__NEXT_DATA__?.props?.pageProps?.colorMode;
return (
<Html lang="en" className={mode}>
<body className="dark:bg-dark-background dark:text-dark-text">
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

In order to get the current color mode set in the page props each of the getServerSideProps functions needs to fetch it and return it in the props. To make this a bit easier I made a higher-order-function that does this and amend the result of the getServerSideProps.

import { GetServerSideProps } from "next";
export function withColorMode<T>(
fn: GetServerSideProps<T>,
): GetServerSideProps<T> {
return async (ctx) => {
const result = await fn(ctx);
const header = ctx.req.headers["sec-ch-prefers-color-scheme"];
const cookie = ctx.req?.cookies?.colorMode;
return {
...result,
props: {
...result.props,
colorMode: cookie || header || null,
},
};
};
}

To use it we wrap the getServerSideProps like below.

export const getServerSideProps = withColorMode(() => ({ props: {} }))
export default ({ colorMode }) => <><strong>Color mode:</strong> <em>{colorMode}</em></>

withColorMode will prefer the cookie over the hint header. The reason for doing that is that the color mode on the site can be set manually with a toggle. Using that toggle will update the cookie, but the header will still follow the setting from the OS.

This should be enough to set the class correctly on the server side of things based on the cookie. To get the hint header to work we need to add the Accept-CH response header. In Next.js we can do that with a middleware by putting the code below in src/middleware.ts.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
response.headers.set("Accept-CH", "Sec-CH-Prefers-Color-Scheme");
return response;
}

Client side

In order to support other browsers and allow the browsers supporting hint headers to override with the color mode toggle we need some client side code as well. Firstly, we need to be able to set and read the cookie we referred to on the server. We can use the js-cookie library to make that a bit easier.

import Cookies from "js-cookie";
function setCookie(mode: string) {
if (typeof window !== "undefined") {
Cookies.set("color-mode", mode, { expires: 7 });
}
}
function getFromCookie(): Mode | undefined {
if (typeof window !== "undefined") {
const value = Cookies.get("color-mode");
if (value === "dark" || value === "light") {
return value;
}
}
}

These will be used to make sure the cookie is present, but also read the default value if the page prop is missing. On the client side the active mode is controlled by a react context to enable overriding it based on user interactions. An effect with the context value as dependency is set to update the class on the html tag by the setModeClass function.

export type Mode = "dark" | "light";
function setModeClass(mode: Mode) {
document.documentElement.classList.remove("light");
document.documentElement.classList.remove("dark");
document.documentElement.classList.add(mode);
}
function getColorMode() {
if (
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
return "dark";
}
return "light";
}
const ModeContext = createContext<[Mode, Dispatch<SetStateAction<Mode>>]>([
"light",
() => {},
]);
export function ModeProvider({
mode,
children,
}: {
mode?: Mode;
children: ReactNode;
}) {
const defaultMode = getFromCookie() || mode || getColorMode();
const modeState = useState<Mode>(defaultMode);
useEffect(() => {
setModeClass(mode);
setCookie(mode);
}, [mode]);
return (
<ModeContext.Provider value={modeState}>{children}</ModeContext.Provider>
);
}
export function useColorMode(): [
Mode,
(mode: ((mode: Mode) => Mode) | Mode) => void,
] {
return useContext(ModeContext);
}

The context provider takes an prop for the default value of the color mode to make it possible to keep the react context in sync with the value derived from the request on the server. This will be provided in the Next.js App component similar to the document component.

import type { AppProps } from "next/app";
import { ModeProvider } from "../useColorMode";
function MyApp({ Component, pageProps }: AppProps): React.ReactElement {
return (
<ModeProvider mode={pageProps.colorMode}>
<Component {...pageProps} />
</ModeProvider>
);
}
export default MyApp;

Listen for changes

The last thing to get in place is to listen for changes to the operative system settings. This is useful to match up if the user is using nightfall or switches the setting some other way.

function registerListener(
setMode: (mode: ((mode: Mode) => Mode) | Mode) => void,
) {
if (typeof window !== "undefined" && window.matchMedia) {
const listener = (event: MediaQueryListEventMap["change"]) => {
setMode(event.matches ? "dark" : "light");
};
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", listener);
return () => {
window
.matchMedia("(prefers-color-scheme: dark)")
.removeEventListener("change", listener);
};
}
}
export function ModeProvider({
mode,
children,
}: {
mode?: Mode;
children: ReactNode;
}) {
const defaultMode = getFromCookie() || mode || getColorMode();
const modeState = useState<Mode>(defaultMode);
useEffect(() => {
return registerListener((mode) => setMode(mode));
}, []);
useEffect(() => {
setModeClass(mode);
setCookie(mode);
}, [mode]);
return (
<ModeContext.Provider value={modeState}>{children}</ModeContext.Provider>
);
}

In conclusion, we now have a Next.js site with dark mode support that will have the correct css when sent from the server so we can avoid the flash of light mode on first render. The first visit will still have light mode default since the cookie is not set and the hint header only works for requests after the Accept-CH response header.