Theme Switcher

This addon provides a theme switching solution for RedwoodSDK projects, enabling users to toggle between light mode, dark mode, and system preferences. It uses server-side cookies to prevent flash of unwanted content (FOUC) and includes React Server Component-compatible providers, RWSDK layouts, and a customizable switch component that works with both Tailwind CSS and vanilla CSS.

Theme Switcher

Redwood Theme Switcher Add On

This addon creates a Theme Switcher for a RedwoodSDK project, allowing the user to easily switch between light mode, dark mode, and system preferences.

How to add to your project

These instructions assume you are starting with a new RedwoodSDK project, for example from npx create-rwsdk -t minimal my-project-name.

0. Decide whether to add this manually or via AI

To use your editor's AI agent support to add this addon for you (e.g. Cursor, VSCode Copilot):

  1. Make sure your project dir is open in your editor. You can create a new project with: npx create-rwsdk -t minimal my-project-name
  2. Open a new AI chat window for your project
  3. Make sure you are in Agent mode
  4. Send the following chat prompt in the chat window - it will do the rest for you!

Please apply this addon to my RedwoodSDK project using these instructions: https://raw.githubusercontent.com/ahaywood/theme-switcher-addon/refs/heads/main/README.md

Alternatively, to apply this addon manually, simply follow the steps below.

1. Download the addon files

npx degit ahaywood/theme-switcher-addon/src _tmp_theme_switcher_addon

2. In the /types directory, add the src/types/theme.d.ts file.

This creates a global type for our theme: light, dark, or system.

3. In the /src/app directory, add the themeSwitcher directory

This includes:

  • ThemeLayout that should wrap your entire project
  • ThemeProvider component, which provides React context for our theme
  • ThemeSwitcher component, which in a bare bones component for updating our theme

4. If the user is using Tailwind within their project (check for @@import "tailwindcss"; at the top of the styles.css file)

Add the following to their src/app/styles.css file, below the @import "tailwindcss"; definition, ensure the file includes:

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

body {
  @apply bg-white text-black dark:bg-black dark:text-white;
}

This sets up the Tailwind dark: custom variant and sets some default styling on the body tag.

5. If the user does not have Tailwind within their project, move the styles.css file into the user's src/app/ directory.

Then, within the src/app/Document.tsx file, import the styles at the top:

import styles from "./styles.css?url";

And add a link definition inside the <head> tag:

<link rel="stylesheet" href={styles} />

6. Within the src/worker.tsx file, we need to apply our Theme layout.

At the top of our file, add layout to the the rwsdk/router imports

import { route, render, prefix, layout } from "rwsdk/router";

and import the ThemeLayout:

import { ThemeLayout } from "./app/themeSwitcher/layouts/ThemeLayout";

Then, wrap all of our routes within the render function's array with our layout function. For example, something like this:

render(Document, [ route("/", Home), prefix("/user", userRoutes) ] },

Would become:

render(Document, [
  layout(ThemeLayout, [
    route("/", Home),
    prefix("/user", userRoutes),
  ]),
]),

7. Run pnpm dev to see the project locally.


Additional Documentation for Using the Theme Switcher Add On within your Project

Architecture Decisions

There are several considerations when working with a theme switcher. You could save the current theme within a cookie or within localStorage (example).

The biggest problem that most people run into is a Flash of Unwanted Content, or "FOUC". You can work around this by injecting JavaScript directly into the <head>.

This works with either cookies or local storage.

With local storage, the moment the document is ready it will read localStorage, and update the class.

But, since we're using cookies, we have an added benefit. Cookies can be read on both the client and server side. With React Server Components, we want to defer to the server as much as possible so that the payload from the server includes the proper theme definition, and there's no unwanted flash.

Working with Providers and Context with React Server Components

One of the confusing aspects for working with React Server Components is knowing what's a server component, what's a client component, and how these are nested.

If your component needs interactivity, like button clicks, or managing state, then it should be a client component.

Everything else is a server component, by default.

You can't render a server component inside a client component. Once you've gone over to the client/browser, you can't come back. Originally, I thought this meant that something like React Context would "ruin" my chain of components. React Context has to be a client component. However, there's a difference between nesting a component and wrapping a component.

❌ THIS WON'T WORK ❌

"use client";

import { ServerComponent } from "./ServerComponent";

export const ClientComponent = () => {
  return (
    <div>
      <ServerComponent />
      <button onClick={() => console.log("clicked")}>
        Button
      </button>
    </div>
  )
}

✅ THIS WILL WORK

// ClientComponent.tsx
"use client";

export const ClientComponent = ({children}) => {
  return (
    <div>
      <button onClick={() => console.log("clicked")}>
        Button
      </button>
      {children}
    </div>
  )
}

// ServerComponent.tsx
export const ServerComponent = ({children}) => {
  return (
    <ClientComponent>
      {children}
    </ClientComponent>
  )
}

Just because a component has the use client directive at the top of your file, doesn't mean that the component only gets rendered on the client side. It actually gets rendered on both the server and the client. The server does as much as it can, and will then hydrate (or update) the component on the client side as needed.

When rendering, server components are rendered first, then client components. That's why we can use React Context and Providers without "ruining" our server -> client flow.

Working with CSS Variables inside your styles.css file

You could set up your project to apply colors through CSS variables. (If you're not using Tailwind CSS, this is the default)

/* Light theme styles */
:root {
  --bg-color: white;
  --text-color: black;
}

/* Dark theme styles */
body:has(.dark),
body:has(.system) {
  @media (prefers-color-scheme: dark) {
    --bg-color: #000;
    --text-color: white;
  }
}

body {
  background-color: var(--bg-color);
  color: var(--text-color);
}

The section at the top defines your light theme styles:

:root {
  --bg-color: white;
  --text-color: black;
}

The section below that defines your dark theme styles:

body:has(.dark),
body:has(.system) {
  @media (prefers-color-scheme: dark) {
    --bg-color: #000;
    --text-color: white;
  }
}

Then, these variables are properly applied to the body tag:

body {
  background-color: var(--bg-color);
  color: var(--text-color);
}

The ThemeSwitcher Component

This is a bare bones component, used to switch the theme. Feel free to adjust this component as necessary or create your own component altogether.

There are 2 key pieces:

  1. We're retrieving the current theme from the ThemeProvider
const { theme, setTheme } = useTheme();

This allows us to access the current theme anywhere within our application. Right now, the ThemeSwitcher component is located inside the ThemeLayout, but it doesn't have to be. You could easily move the ThemeSwitcher to another layout or component with your project, such as a Header component.

  1. The theme gets set by calling the setTheme function and passing in light, dark, or system

For example:

<button
  onClick={() => {
    setTheme("light");
  }}
>light</button>

ThemeProvider

The ThemeProvider contains standard React Context.

However, there are still a few things worth noting:

Cookie Expiration

The cookie is set to expire in 10 years. You must set an expiration date. If you leave off the expires parameter, then the cookie will automatically expire when the session ends, when the browser closes.

// Set the cookie with 10 years expiration
const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 10);
document.cookie = `theme=${newTheme}; expires=${expirationDate.toUTCString()}; path=/`;

Setting the className

When the ThemeProvider is rendered, it returns:

<div className={theme}>{children}</div>

This applies the theme name as a className on the wrapping div. We're able to target this, either from our custom Tailwind directive:

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

Or within our standard CSS:

body:has(.dark),
body:has(.system) {
  @media (prefers-color-scheme: dark) {
    ...
  }
}

useTheme

From our ThemeProvider component, we're also exporting a useTheme hook:

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

This wraps our useContext hook, but makes our API cleaner. You may noticed, we're using this within our ThemeSwitcher component to access the current theme:

const { theme, setTheme } = useTheme();

ThemeLayout

The ThemeLayout is simplistic in nature. It gets the theme saved within our cookie

const theme = getCookie(requestInfo?.request ?? new Request(""), "theme");

and passes it into the Provider, which wraps everything:

<ThemeProvider theme={theme as Theme}>
  <ThemeSwitcher />
  {children}
</ThemeProvider>

The getCookie function may look a little different than the standard JavaScript cookies (W3 Schools Documentation) you usually work with.

But, that's because we're accessing the cookies sent to the server from the browser's request. We're using a custom getCookie function which takes two parameters:

  1. The request
  2. The name of the cookie. In our case, theme

Within a RedwoodSDK layout, we automatically get a requestInfo object that includes the request. But, just in case, we've also included a fallback new Request("")

const theme = getCookie(requestInfo?.request ?? new Request(""), "theme");

When you access cookies (from either the server or client side) it returns all the cookies in one long string, much like: cookie1=value; cookie2=value; cookie3=value; This needs to be parsed in order to find the exact key value pair that you're looking for. Our getCookie helper function handles all of that for you, returning the appropriate value:

function getCookie(request: Request, name: string): string | null {
  const cookieHeader = request.headers.get("cookie");
  if (!cookieHeader) return null;

  const cookies = cookieHeader.split("; ");
  const cookie = cookies.find((row) => row.startsWith(`${name}=`));
  return cookie ? decodeURIComponent(cookie.split("=")[1]) : null;
}

Theme Types

We created a custom Theme type, available globally and located within types/theme.d.ts.

If you choose to customize your themes further, like rwsdk-light or rwsdk-dark, you'll need to add these strings to your type definition.