Color mode with nextjs
Posted 30.12.2023 · 6 min readIn 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.