How toAdd a Color Scheme Toggle in React Router

TL;DR: Here's a repository https://github.com/sergiodxa/react-router-color-scheme-example with a working example of this tutorial.

Let's say you're building an app that supports light and dark mode. Usually, this is called a theme toggle, but the correct term is a color scheme toggle, as you can have multiple themes with both light and dark color schemes.

Putting terminology aside, the simplest way to implement a color scheme toggle is to use the prefers-color-scheme media query, which is supported by all modern browsers.

This makes everything simpler, as you just need to react to the user's system preference. You don't have to worry about a flash of incorrect color scheme when the user reloads the page, as CSS will correctly apply the color scheme based on the user's system preference automatically.

But for the sake of this tutorial, let's say you want to let users choose their own color scheme for your app — maybe they want to use a light theme even if their system is set to dark mode.

In that case, you need to store the user's preference somewhere. The most common place is localStorage, but there's a problem with that: if the user reloads the page, the app will briefly flash the default color scheme before applying the user's preference.

To solve this, most apps inject an inline script in the HTML that reads the user's preference from localStorage and applies it to the document (by adding a class to the <html> tag) before the DOM is rendered.

There’s another, simpler way: use a cookie to store the user's preference, then read the cookie on the server and set the initial color scheme in the HTML before sending it to the browser.

Let’s say we're using Tailwind for styling. We can override the .dark variant so it works with classes instead of media queries.

@custom-variant dark {
  &:where(.dark *, .dark) {
    @slot;
  }

  &:where(.system *, .system) {
    @media (prefers-color-scheme: dark) {
      @slot;
    }
  }
}

This custom variant will apply the dark: styles if the element is inside an element with the dark class, or if it’s inside an element with the system class and the user's system is set to dark mode.

The second part is important because if the cookie is not set, we want to fall back to using the system preference detected by the prefers-color-scheme media query — or if the user explicitly chooses to follow the system preference.

Now, we need to create our cookie.

import { createCookie } from "react-router";
import { createTypedCookie } from "remix-utils/typed-cookie";
import { z } from "zod";

// Create a cookie using React Router's createCookie API
const cookie = createCookie("color-scheme", {
  path: "/",
  sameSite: "lax",
  httpOnly: true,
  secrets: [process.env.COOKIE_SECRET ?? "secret"],
});

// Create a Zod schema to validate the cookie value
export const schema = z
  .enum(["dark", "light", "system"]) // Possible color schemes
  .default("system") // If no cookie, default to "system"
  .catch("system"); // In case of an error, default to "system"

// Use Remix Utils to ensure the cookie value is always parsed
const typedCookie = createTypedCookie({ cookie, schema });

// Helpers to get and set the cookie
export function getColorScheme(request: Request) {
  const colorScheme = typedCookie.parse(request.headers.get("Cookie"));
  return colorScheme ?? "system";
}

export async function setColorScheme(colorScheme: string) {
  return await typedCookie.serialize(colorScheme);
}

Now, let’s go to our app/root.tsx file and add a loader to read the cookie and set the initial color scheme in the HTML.

export async function loader({ request }: Route.LoaderArgs) {
  let colorScheme = await getColorScheme(request);
  return { colorScheme };
}

export function Layout({ children }: { children: React.ReactNode }) {
  let loaderData = useRouteLoaderData<typeof loader>("root");
  return (
    <html lang="en" className={loaderData?.colorScheme ?? "system"}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Notice how in the Layout component we use the useRouteLoaderData hook instead of useLoaderData. This is because the root loader may throw, and our Layout will still be rendered (wrapping the ErrorBoundary). By using useRouteLoaderData, the loader data can be undefined, and we can fall back to the default value of "system" in the className.

Now we need to add a way to set the cookie. We can do this with an action and a form.

export async function action({ request }: Route.ActionArgs) {
  let formData = await request.formData();
  let colorScheme = schema.parse(formData.get("color-scheme"));
  return data(null, {
    headers: { "Set-Cookie": await setColorScheme(colorScheme) },
  });
}

export default function Component() {
  return (
    <>
      <Form navigate={false} method="POST" className="p-10">
        <div className="flex items-center justify-center gap-4">
          <button
            type="submit"
            name="color-scheme"
            value="dark"
            className="rounded-full bg-gray-900 px-4 py-2 text-white dark:bg-gray-100 dark:text-gray-900"
          >
            Dark
          </button>
          <button
            type="submit"
            name="color-scheme"
            value="light"
            className="rounded-full bg-gray-900 px-4 py-2 text-white dark:bg-gray-100 dark:text-gray-900"
          >
            Light
          </button>
          <button
            type="submit"
            name="color-scheme"
            value="system"
            className="rounded-full bg-gray-900 px-4 py-2 text-white dark:bg-gray-100 dark:text-gray-900"
          >
            System
          </button>
        </div>
      </Form>
    </>
  );
}

This form will send the color scheme selected by the user to the action, which will set the cookie and return a response with the updated value. The navigate={false} prop prevents the form from triggering a page navigation, so the user will see the new color scheme immediately.

After the action runs, React Router revalidates the loaders, so the new cookie is read and the updated color scheme is applied to the <html> tag, updating the styles automatically.