# Add a Color Scheme Toggle in React Router

Used: tailwindcss@4.0.0 and react-router@7.0.0

> 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.

```css
@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.

```ts
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.

```tsx
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.

```tsx
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.
